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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user