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,127 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteContainer } from "../server/actions";
import { ContainerForm } from "./container-form";
import type { ContainerDetailDto } from "../server/queries";
type Props = { container: ContainerDetailDto };
export function ContainerDetail({ container }: Props) {
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function handleDelete() {
startTransition(async () => {
await deleteContainer({ id: container.id });
router.push("/garden");
router.refresh();
});
}
return (
<div className="flex flex-col gap-6">
{container.coverImageUrl && (
<img
src={container.coverImageUrl}
alt=""
className="w-full max-h-48 object-cover rounded-lg"
/>
)}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{container.name}</h1>
<p className="text-sm text-[var(--ink-mute)] capitalize mt-1">{container.type}</p>
{container.locationNotes && <p className="text-sm mt-2">{container.locationNotes}</p>}
</div>
<div className="flex gap-2 shrink-0">
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(true)}>
Edit
</button>
{confirming ? (
<div className="flex gap-1">
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
)}
</div>
</div>
{editing && (
<div className="card p-4">
<h2 className="font-medium mb-3">Edit container</h2>
<ContainerForm
existing={container}
onSuccess={() => {
setEditing(false);
router.refresh();
}}
onCancel={() => setEditing(false)}
/>
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<a
href={`/garden/plants/new?containerId=${container.id}`}
className="btn btn-ghost btn-sm"
>
+ Add plant
</a>
</div>
{container.plants.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{container.plants.map((p) => (
<a
key={p.id}
href={`/garden/plants/${p.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{p.primaryImageUrl && (
<img
src={p.primaryImageUrl}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
)}
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.scientificName && (
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
)}
</div>
<span
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
>
{p.healthStatus}
</span>
</div>
</a>
))}
</div>
)}
</div>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ContainerForm } from "./container-form";
import type { ContainerDto } from "../server/queries";
type Props = { containers: ContainerDto[] };
export function ContainerList({ containers }: Props) {
const [showNew, setShowNew] = useState(false);
const router = useRouter();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Containers</h2>
<button className="btn btn-primary btn-sm" onClick={() => setShowNew(true)}>
New container
</button>
</div>
{showNew && (
<div className="card p-4">
<h3 className="font-medium mb-3">New container</h3>
<ContainerForm
onSuccess={() => {
setShowNew(false);
router.refresh();
}}
onCancel={() => setShowNew(false)}
/>
</div>
)}
{containers.length === 0 && !showNew && (
<p className="text-sm text-[var(--ink-mute)]">
No containers yet. Add one to start organising your plants.
</p>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{containers.map((c) => (
<Link
key={c.id}
href={`/garden/containers/${c.id}`}
className="card p-4 hover:bg-[var(--surface-2)] transition-colors"
>
{c.coverImageUrl && (
<img src={c.coverImageUrl} alt="" className="w-full h-32 object-cover rounded mb-3" />
)}
<div className="flex items-start justify-between gap-2">
<span className="font-medium leading-tight">{c.name}</span>
<span className="badge badge-outline text-xs shrink-0 capitalize">{c.type}</span>
</div>
<p className="text-sm text-[var(--ink-mute)] mt-1">
{c.plantCount} {c.plantCount === 1 ? "plant" : "plants"}
</p>
</Link>
))}
</div>
</div>
);
}