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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user