From f3e38c576c583b474e0dd5eb6d0c43735bef3eee Mon Sep 17 00:00:00 2001 From: ginnoir Date: Mon, 1 Jun 2026 20:13:20 -0500 Subject: [PATCH] feat: garden plants crud (task 72) - perenual species lookup with 24h db cache (api key optional) - /api/garden/species-search route handler - createPlant / updatePlant / deletePlant server actions - addPlantImage / removePlantImage / setPrimaryImage actions (max 10) - listPlants query grouped by container with last-watered subquery - getPlant query with care log summary and active schedules - plant-form client component with species autofill and image upload - plant-list server component grouped by container with unassigned bucket - plant-detail client component with info/gallery/care tabs - species-search debounced combobox component - /garden/plants/new, /[id], /[id]/edit pages - garden.plant entity registered with search and share support - /garden page updated to containers/plants tabs (default: containers) --- src/app/api/garden/species-search/route.ts | 17 + src/app/garden/page.tsx | 43 +- src/app/garden/plants/[id]/edit/page.tsx | 16 + src/app/garden/plants/[id]/page.tsx | 15 + src/app/garden/plants/new/page.tsx | 18 + .../garden/components/plant-detail.tsx | 259 ++++++++++++ src/modules/garden/components/plant-form.tsx | 367 ++++++++++++++++++ src/modules/garden/components/plant-list.tsx | 106 +++++ .../garden/components/species-search.tsx | 107 +++++ src/modules/garden/manifest.tsx | 79 +++- src/modules/garden/server/actions.ts | 212 +++++++++- src/modules/garden/server/queries.ts | 214 +++++++++- src/modules/garden/server/share-queries.ts | 39 ++ src/modules/garden/server/species-lookup.ts | 114 ++++++ 14 files changed, 1598 insertions(+), 8 deletions(-) create mode 100644 src/app/api/garden/species-search/route.ts create mode 100644 src/app/garden/plants/[id]/edit/page.tsx create mode 100644 src/app/garden/plants/[id]/page.tsx create mode 100644 src/app/garden/plants/new/page.tsx create mode 100644 src/modules/garden/components/plant-detail.tsx create mode 100644 src/modules/garden/components/plant-form.tsx create mode 100644 src/modules/garden/components/plant-list.tsx create mode 100644 src/modules/garden/components/species-search.tsx create mode 100644 src/modules/garden/server/species-lookup.ts diff --git a/src/app/api/garden/species-search/route.ts b/src/app/api/garden/species-search/route.ts new file mode 100644 index 0000000..4fd2e29 --- /dev/null +++ b/src/app/api/garden/species-search/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { getCurrentSession } from "@/lib/session"; +import { searchSpecies } from "@/modules/garden/server/species-lookup"; + +export async function GET(request: Request) { + try { + await getCurrentSession(); + } catch { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q") ?? ""; + + const results = await searchSpecies(q); + return NextResponse.json(results); +} diff --git a/src/app/garden/page.tsx b/src/app/garden/page.tsx index 148558c..fbe29df 100644 --- a/src/app/garden/page.tsx +++ b/src/app/garden/page.tsx @@ -1,12 +1,47 @@ -import { listContainers } from "@/modules/garden/server/queries"; +import Link from "next/link"; +import { listContainers, listPlants } from "@/modules/garden/server/queries"; import { ContainerList } from "@/modules/garden/components/container-list"; +import { PlantList } from "@/modules/garden/components/plant-list"; + +type Props = { + searchParams: Promise<{ tab?: string }>; +}; + +export default async function GardenPage({ searchParams }: Props) { + const { tab = "containers" } = await searchParams; + const isPlants = tab === "plants"; + + const [containers, plants] = await Promise.all([ + listContainers(), + isPlants ? listPlants() : Promise.resolve([]), + ]); -export default async function GardenPage() { - const containers = await listContainers(); return (

Garden

- + +
+ + Containers + + + Plants + +
+ + {isPlants ? : }
); } diff --git a/src/app/garden/plants/[id]/edit/page.tsx b/src/app/garden/plants/[id]/edit/page.tsx new file mode 100644 index 0000000..84da193 --- /dev/null +++ b/src/app/garden/plants/[id]/edit/page.tsx @@ -0,0 +1,16 @@ +import { notFound } from "next/navigation"; +import { getPlant, listContainers } from "@/modules/garden/server/queries"; +import { PlantForm } from "@/modules/garden/components/plant-form"; + +export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const [plant, containers] = await Promise.all([getPlant(id), listContainers()]); + if (!plant) notFound(); + + return ( +
+

Edit Plant

+ +
+ ); +} diff --git a/src/app/garden/plants/[id]/page.tsx b/src/app/garden/plants/[id]/page.tsx new file mode 100644 index 0000000..2cd02bd --- /dev/null +++ b/src/app/garden/plants/[id]/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; +import { getPlant } from "@/modules/garden/server/queries"; +import { PlantDetail } from "@/modules/garden/components/plant-detail"; + +export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const plant = await getPlant(id); + if (!plant) notFound(); + + return ( +
+ +
+ ); +} diff --git a/src/app/garden/plants/new/page.tsx b/src/app/garden/plants/new/page.tsx new file mode 100644 index 0000000..5739a86 --- /dev/null +++ b/src/app/garden/plants/new/page.tsx @@ -0,0 +1,18 @@ +import { listContainers } from "@/modules/garden/server/queries"; +import { PlantForm } from "@/modules/garden/components/plant-form"; + +export default async function NewPlantPage({ + searchParams, +}: { + searchParams: Promise<{ containerId?: string }>; +}) { + const params = await searchParams; + const containers = await listContainers(); + + return ( +
+

Add Plant

+ +
+ ); +} diff --git a/src/modules/garden/components/plant-detail.tsx b/src/modules/garden/components/plant-detail.tsx new file mode 100644 index 0000000..8435abe --- /dev/null +++ b/src/modules/garden/components/plant-detail.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions"; +import type { PlantDetailDto } from "../server/queries"; + +type Tab = "info" | "gallery" | "care"; + +type Props = { plant: PlantDetailDto }; + +function InfoRow({ + label, + value, + children, +}: { + label: string; + value?: string | null; + children?: React.ReactNode; +}) { + if (!value && !children) return null; + return ( +
+ {label} + {children ?? value} +
+ ); +} + +function healthBadgeClass(status: string): string { + if (status === "healthy") return "badge-success"; + if (status === "sick") return "badge-danger"; + return "badge-warning"; +} + +export function PlantDetail({ plant }: Props) { + const [tab, setTab] = useState("info"); + const [confirming, setConfirming] = useState(false); + const [isPending, startTransition] = useTransition(); + const [galleryError, setGalleryError] = useState(null); + const [uploadingImage, setUploadingImage] = useState(false); + const router = useRouter(); + + function handleDelete() { + startTransition(async () => { + await deletePlant({ id: plant.id }); + router.push("/garden"); + router.refresh(); + }); + } + + async function handleImageUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setGalleryError(null); + setUploadingImage(true); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/uploads", { method: "POST", body: fd }); + if (!res.ok) throw new Error("Upload failed"); + const data = (await res.json()) as { url: string }; + await addPlantImage({ id: plant.id, url: data.url }); + router.refresh(); + } catch { + setGalleryError("Image upload failed."); + } finally { + setUploadingImage(false); + e.target.value = ""; + } + } + + function handleRemoveImage(url: string) { + startTransition(async () => { + await removePlantImage({ id: plant.id, url }); + router.refresh(); + }); + } + + function handleSetPrimary(url: string) { + startTransition(async () => { + await setPrimaryImage({ id: plant.id, url }); + router.refresh(); + }); + } + + return ( +
+ {/* Header */} +
+
+ {plant.primaryImageUrl && ( + + )} +
+

{plant.name}

+ {plant.scientificName && ( +

{plant.scientificName}

+ )} +
+ + {plant.healthStatus} + + {plant.growthStage && ( + {plant.growthStage} + )} +
+
+
+ +
+ + Edit + + {confirming ? ( +
+ + +
+ ) : ( + + )} +
+
+ + {/* Tabs */} +
+ {(["info", "gallery", "care"] as Tab[]).map((t) => ( + + ))} +
+ + {/* Info */} + {tab === "info" && ( +
+ + {plant.containerName && ( + + + {plant.containerName} + + + )} + + + + + + {plant.recentCareLogs.length > 0 && ( +
+

+ Recent care +

+
    + {plant.recentCareLogs.map((log) => ( +
  • + {log.careType} + + {new Date(log.performedAt).toLocaleDateString()} + + {log.notes && — {log.notes}} +
  • + ))} +
+
+ )} +
+ )} + + {/* Gallery */} + {tab === "gallery" && ( +
+ {plant.images.length === 0 ? ( +

No photos yet.

+ ) : ( +
+ {plant.images.map((url) => ( +
+ +
+ + +
+ {url === plant.primaryImageUrl && ( + + Primary + + )} +
+ ))} +
+ )} + + {galleryError &&

{galleryError}

} + +
+ {plant.images.length < 10 && ( + + )} + {plant.images.length}/10 photos +
+
+ )} + + {/* Care */} + {tab === "care" && ( +

Care tracking coming soon.

+ )} +
+ ); +} diff --git a/src/modules/garden/components/plant-form.tsx b/src/modules/garden/components/plant-form.tsx new file mode 100644 index 0000000..6f2d74b --- /dev/null +++ b/src/modules/garden/components/plant-form.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { useState, useTransition, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { createPlant, updatePlant } from "../server/actions"; +import { SpeciesSearch } from "./species-search"; +import type { PlantDetailDto, ContainerDto } from "../server/queries"; +import type { SpeciesSuggestion } from "../server/species-lookup"; + +const CATEGORIES = [ + { value: "vegetable", label: "Vegetable" }, + { value: "fruit", label: "Fruit" }, + { value: "herb", label: "Herb" }, + { value: "flower", label: "Flower" }, + { value: "succulent", label: "Succulent" }, + { value: "cactus", label: "Cactus" }, + { value: "tropical", label: "Tropical" }, + { value: "tree", label: "Tree" }, + { value: "shrub", label: "Shrub" }, + { value: "other", label: "Other" }, +]; + +const HEALTH_STATUSES = [ + { value: "healthy", label: "Healthy" }, + { value: "needs-attention", label: "Needs attention" }, + { value: "sick", label: "Sick" }, + { value: "dormant", label: "Dormant" }, +]; + +const GROWTH_STAGES = [ + { value: "", label: "Unknown" }, + { value: "seedling", label: "Seedling" }, + { value: "sprout", label: "Sprout" }, + { value: "vegetative", label: "Vegetative" }, + { value: "budding", label: "Budding" }, + { value: "flowering", label: "Flowering" }, + { value: "ripening", label: "Ripening" }, + { value: "dormant", label: "Dormant" }, +]; + +type Props = { + existingPlant?: PlantDetailDto; + defaultContainerId?: string | null; + containers: ContainerDto[]; +}; + +export function PlantForm({ existingPlant, defaultContainerId, containers }: Props) { + const router = useRouter(); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + const [images, setImages] = useState(existingPlant?.images ?? []); + const [uploading, setUploading] = useState(false); + const [selectedSpeciesId, setSelectedSpeciesId] = useState(existingPlant?.speciesId ?? ""); + + const scientificNameRef = useRef(null); + const sunlightRef = useRef(null); + const wateringNotesRef = useRef(null); + + function handleSpeciesSelect(species: SpeciesSuggestion) { + setSelectedSpeciesId(species.id); + if (scientificNameRef.current) scientificNameRef.current.value = species.scientific_name; + if (sunlightRef.current) sunlightRef.current.value = species.sunlight.join(", "); + if (wateringNotesRef.current) wateringNotesRef.current.value = species.watering; + } + + async function handleImageSelect(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + if (images.length >= 10) { + setError("Maximum 10 images allowed."); + return; + } + setUploading(true); + setError(null); + try { + const urls: string[] = []; + for (const file of files) { + if (images.length + urls.length >= 10) break; + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/uploads", { method: "POST", body: fd }); + if (!res.ok) throw new Error("Upload failed"); + const data = (await res.json()) as { url: string }; + urls.push(data.url); + } + setImages((prev) => [...prev, ...urls]); + } catch { + setError("Image upload failed. Please try again."); + } finally { + setUploading(false); + e.target.value = ""; + } + } + + function handleRemoveImage(url: string) { + setImages((prev) => prev.filter((u) => u !== url)); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + + const input = { + name: fd.get("name") as string, + category: (fd.get("category") as string) || "other", + containerId: (fd.get("containerId") as string) || null, + healthStatus: (fd.get("healthStatus") as string) || "healthy", + growthStage: (fd.get("growthStage") as string) || null, + scientificName: scientificNameRef.current?.value || null, + speciesId: selectedSpeciesId || null, + sunlight: sunlightRef.current?.value || null, + wateringNotes: wateringNotesRef.current?.value || null, + fertilizingNotes: (fd.get("fertilizingNotes") as string) || null, + notes: (fd.get("notes") as string) || null, + acquisitionDate: (fd.get("acquisitionDate") as string) || null, + images, + primaryImageUrl: existingPlant?.primaryImageUrl ?? images[0] ?? null, + }; + + setError(null); + startTransition(async () => { + try { + if (existingPlant) { + await updatePlant({ id: existingPlant.id, ...input }); + router.push(`/garden/plants/${existingPlant.id}`); + router.refresh(); + } else { + const plant = await createPlant(input); + router.push(`/garden/plants/${plant.id}`); + } + } catch { + setError("Something went wrong. Please try again."); + } + }); + } + + return ( +
+
+ + +
+ +
+ + +

+ Search Perenual to auto-fill scientific name and care notes. +

+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +