feat: garden containers crud (task 71)

- add listContainers/getContainer/searchContainers queries
- add createContainer/updateContainer/deleteContainer server actions with logActivity
- add ContainerList, ContainerDetail, ContainerForm client components
- add /garden, /garden/containers/[id], /garden/containers/new pages
- register garden.container entity with share + search in manifest
- add sprout icon to NavIcon registry
- add garden e2e test spec
This commit is contained in:
ginnoir
2026-06-01 19:28:34 -05:00
parent 2fd0677c5f
commit 5f6b756342
12 changed files with 756 additions and 4 deletions
@@ -0,0 +1,112 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { createContainer, updateContainer } from "../server/actions";
import type { ContainerDto } from "../server/queries";
const CONTAINER_TYPES = [
{ value: "shelf", label: "Shelf" },
{ value: "terrarium", label: "Terrarium" },
{ value: "raised-bed", label: "Raised bed" },
{ value: "window-box", label: "Window box" },
{ value: "single-pot", label: "Single pot" },
{ value: "outdoor", label: "Outdoor" },
{ value: "other", label: "Other" },
];
type Props = {
existing?: ContainerDto;
onSuccess?: () => void;
onCancel?: () => void;
};
export function ContainerForm({ existing, onSuccess, onCancel }: Props) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const input = {
name: fd.get("name") as string,
type: fd.get("type") as string,
locationNotes: (fd.get("locationNotes") as string) || null,
};
setError(null);
startTransition(async () => {
try {
if (existing) {
await updateContainer({ id: existing.id, ...input });
} else {
await createContainer(input);
}
formRef.current?.reset();
onSuccess?.();
} catch {
setError("Something went wrong. Please try again.");
}
});
}
return (
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="container-name" className="text-sm font-medium">
Name
</label>
<input
id="container-name"
name="name"
required
maxLength={120}
defaultValue={existing?.name}
placeholder="e.g. Living Room Shelf"
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="container-type" className="text-sm font-medium">
Type
</label>
<select id="container-type" name="type" defaultValue={existing?.type ?? "other"}>
{CONTAINER_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="container-notes" className="text-sm font-medium">
Location notes
</label>
<textarea
id="container-notes"
name="locationNotes"
maxLength={500}
rows={2}
defaultValue={existing?.locationNotes ?? ""}
placeholder="Optional — e.g. south-facing window"
className="input"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
{onCancel && (
<button type="button" onClick={onCancel} className="btn btn-ghost" disabled={isPending}>
Cancel
</button>
)}
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? "Saving…" : existing ? "Save" : "Create"}
</button>
</div>
</form>
);
}