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
+77 -2
View File
@@ -1,12 +1,81 @@
import type { ModuleManifest } from "../_core/module";
import { loadContainerForShare, type ContainerShareData } from "./server/share-queries";
import { searchContainers } from "./server/queries";
import {
loadContainerForShare,
loadPlantForShare,
type ContainerShareData,
type PlantShareData,
} from "./server/share-queries";
import { searchContainers, searchPlants } from "./server/queries";
const gardenManifest: ModuleManifest = {
id: "garden",
name: "Garden",
nav: { href: "/garden", label: "Garden", icon: "sprout" },
entities: [
{
type: "garden.plant",
label: { singular: "Plant", plural: "Plants" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: (id) => loadPlantForShare(id),
renderSharedView: ({ data }) => {
const d = data as PlantShareData;
return (
<div className="flex flex-col gap-3">
{d.primaryImageUrl && (
<img
src={d.primaryImageUrl}
alt=""
className="w-full max-h-48 object-cover rounded-lg"
/>
)}
<h2 className="text-xl font-bold">{d.name}</h2>
{d.scientificName && (
<p className="text-sm italic text-[var(--ink-mute)]">{d.scientificName}</p>
)}
<p className="text-sm capitalize">
{d.healthStatus} · {d.category}
</p>
{d.wateringNotes && (
<div>
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] mb-1">
Watering
</p>
<p className="text-sm">{d.wateringNotes}</p>
</div>
)}
{d.fertilizingNotes && (
<div>
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] mb-1">
Fertilizing
</p>
<p className="text-sm">{d.fertilizingNotes}</p>
</div>
)}
{d.notes && <p className="text-sm">{d.notes}</p>}
{d.images.length > 1 && (
<div className="grid grid-cols-3 gap-2 mt-1">
{d.images.slice(1).map((url) => (
<img
key={url}
src={url}
alt=""
className="w-full aspect-square object-cover rounded"
/>
))}
</div>
)}
</div>
);
},
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
if (entry.action === "create") return `Added plant${name ? ` "${name}"` : ""}`;
if (entry.action === "delete") return `Removed plant${name ? ` "${name}"` : ""}`;
return `Updated${name ? ` "${name}"` : " plant"}`;
},
},
{
type: "garden.container",
label: { singular: "Container", plural: "Containers" },
@@ -51,6 +120,12 @@ const gardenManifest: ModuleManifest = {
],
dashboardWidgets: [],
quickAdds: [
{
id: "garden.add-plant",
label: "Add plant",
icon: "sprout",
url: "/garden/plants/new",
},
{
id: "garden.add-container",
label: "Add container",