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)
This commit is contained in:
ginnoir
2026-06-01 20:13:20 -05:00
parent a86f5471ce
commit f3e38c576c
14 changed files with 1598 additions and 8 deletions
@@ -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);
}
+39 -4
View File
@@ -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 (
<div className="page-content">
<h1 className="page-title">Garden</h1>
<ContainerList containers={containers} />
<div className="flex gap-6 border-b border-[var(--ink-faint)] mb-6">
<Link
href="/garden?tab=containers"
className={`pb-2 text-sm font-medium transition-colors ${
!isPlants
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)]"
}`}
>
Containers
</Link>
<Link
href="/garden?tab=plants"
className={`pb-2 text-sm font-medium transition-colors ${
isPlants ? "border-b-2 border-[var(--ink)] text-[var(--ink)]" : "text-[var(--ink-mute)]"
}`}
>
Plants
</Link>
</div>
{isPlants ? <PlantList plants={plants} /> : <ContainerList containers={containers} />}
</div>
);
}
+16
View File
@@ -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 (
<div className="page-content max-w-xl">
<h1 className="page-title">Edit Plant</h1>
<PlantForm existingPlant={plant} containers={containers} />
</div>
);
}
+15
View File
@@ -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 (
<div className="page-content">
<PlantDetail plant={plant} />
</div>
);
}
+18
View File
@@ -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 (
<div className="page-content max-w-xl">
<h1 className="page-title">Add Plant</h1>
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
</div>
);
}