v0.1.1 — Garden: edit and delete plants
This commit is contained in:
9330
package-lock.json
generated
Normal file
9330
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
prisma/.DS_Store
vendored
Normal file
BIN
prisma/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
prisma/migrations/.DS_Store
vendored
Normal file
BIN
prisma/migrations/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -11,7 +11,7 @@ async function main() {
|
|||||||
|
|
||||||
await db.user.upsert({
|
await db.user.upsert({
|
||||||
where: { username: "bonna" },
|
where: { username: "bonna" },
|
||||||
update: {},
|
update: { passwordHash: password },
|
||||||
create: {
|
create: {
|
||||||
id: "cluser0000000bonna",
|
id: "cluser0000000bonna",
|
||||||
email: "bonna@moonbase.local",
|
email: "bonna@moonbase.local",
|
||||||
@@ -25,7 +25,7 @@ async function main() {
|
|||||||
|
|
||||||
await db.user.upsert({
|
await db.user.upsert({
|
||||||
where: { username: "tony" },
|
where: { username: "tony" },
|
||||||
update: {},
|
update: { passwordHash: password },
|
||||||
create: {
|
create: {
|
||||||
id: "cluser0000000tony0",
|
id: "cluser0000000tony0",
|
||||||
email: "tony@moonbase.local",
|
email: "tony@moonbase.local",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MapPin, CalendarDays, ChevronLeft, Sprout, Package } from "lucide-react
|
|||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
||||||
import { AddLogButton } from "@/components/garden/add-log-button";
|
import { AddLogButton } from "@/components/garden/add-log-button";
|
||||||
|
import { EditPlantButton } from "@/components/garden/edit-plant-button";
|
||||||
|
import { DeletePlantButton } from "@/components/garden/delete-plant-button";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: { id: string } }) {
|
export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||||
const plant = await db.plant.findUnique({ where: { id: params.id }, select: { commonName: true } });
|
const plant = await db.plant.findUnique({ where: { id: params.id }, select: { commonName: true } });
|
||||||
@@ -41,6 +43,11 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<EditPlantButton plant={plant} />
|
||||||
|
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 space-y-2 text-sm">
|
<CardContent className="pt-4 space-y-2 text-sm">
|
||||||
{plant.zone && (
|
{plant.zone && (
|
||||||
|
|||||||
84
src/app/api/plants/[id]/route.ts
Normal file
84
src/app/api/plants/[id]/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||||
|
import { PlantCategory } from "@prisma/client";
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
commonName: z.string().min(1),
|
||||||
|
species: z.string().optional(),
|
||||||
|
variety: z.string().optional(),
|
||||||
|
category: z.nativeEnum(PlantCategory),
|
||||||
|
zone: z.string().optional(),
|
||||||
|
plantedAt: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const plant = await db.plant.findUnique({ where: { id: params.id } });
|
||||||
|
if (!plant || !plant.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const data = updateSchema.parse(body);
|
||||||
|
|
||||||
|
const updated = await db.plant.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: {
|
||||||
|
commonName: data.commonName.trim(),
|
||||||
|
species: data.species?.trim() || null,
|
||||||
|
variety: data.variety?.trim() || null,
|
||||||
|
category: data.category,
|
||||||
|
zone: data.zone?.trim() || null,
|
||||||
|
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
|
||||||
|
source: data.source?.trim() || null,
|
||||||
|
notes: data.notes?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "plant.update",
|
||||||
|
entityId: params.id,
|
||||||
|
actorId: session.user.id,
|
||||||
|
payload: { commonName: updated.commonName },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues }, { status: 400 });
|
||||||
|
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!isAdmin(session.user.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const plant = await db.plant.findUnique({ where: { id: params.id } });
|
||||||
|
if (!plant || !plant.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await db.plant.update({ where: { id: params.id }, data: { active: false } });
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "plant.delete",
|
||||||
|
entityId: params.id,
|
||||||
|
actorId: session.user.id,
|
||||||
|
payload: { commonName: plant.commonName },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/components/garden/delete-plant-button.tsx
Normal file
64
src/components/garden/delete-plant-button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plantId: string;
|
||||||
|
plantName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeletePlantButton({ plantId, plantName }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
async function onDelete() {
|
||||||
|
setBusy(true);
|
||||||
|
const res = await fetch(`/api/plants/${plantId}`, { method: "DELETE" });
|
||||||
|
setBusy(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
toast({ title: "Error", description: err.error ?? "Could not delete plant.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: `${plantName} removed.` });
|
||||||
|
setOpen(false);
|
||||||
|
router.push("/garden");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setOpen(true)}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove {plantName}?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This will remove the plant and all its log entries from your registry.
|
||||||
|
You can always add it back later.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={onDelete} disabled={busy}>
|
||||||
|
{busy ? "Removing…" : "Yes, remove it"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/components/garden/edit-plant-button.tsx
Normal file
157
src/components/garden/edit-plant-button.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Plant, PlantCategory } from "@prisma/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
commonName: z.string().min(1, "Name is required"),
|
||||||
|
species: z.string().optional(),
|
||||||
|
variety: z.string().optional(),
|
||||||
|
category: z.nativeEnum(PlantCategory),
|
||||||
|
zone: z.string().optional(),
|
||||||
|
plantedAt: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function toDateInput(d: Date | null): string {
|
||||||
|
if (!d) return "";
|
||||||
|
return new Date(d).toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditPlantButton({ plant }: { plant: Plant }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
commonName: plant.commonName,
|
||||||
|
species: plant.species ?? "",
|
||||||
|
variety: plant.variety ?? "",
|
||||||
|
category: plant.category,
|
||||||
|
zone: plant.zone ?? "",
|
||||||
|
plantedAt: toDateInput(plant.plantedAt),
|
||||||
|
source: plant.source ?? "",
|
||||||
|
notes: plant.notes ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
const res = await fetch(`/api/plants/${plant.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
toast({ title: "Error", description: err.error ?? "Could not save changes.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: "Saved!" });
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<Pencil className="h-4 w-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit plant</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="edit-commonName">Common name *</Label>
|
||||||
|
<Input id="edit-commonName" {...form.register("commonName")} />
|
||||||
|
{form.formState.errors.commonName && (
|
||||||
|
<p className="text-xs text-destructive">{form.formState.errors.commonName.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-variety">Variety / cultivar</Label>
|
||||||
|
<Input id="edit-variety" {...form.register("variety")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Category *</Label>
|
||||||
|
<Select
|
||||||
|
defaultValue={plant.category}
|
||||||
|
onValueChange={(v) => form.setValue("category", v as PlantCategory)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_ORDER.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="edit-species">Scientific name</Label>
|
||||||
|
<Input id="edit-species" className="italic" {...form.register("species")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="edit-zone">Location on your property</Label>
|
||||||
|
<Input id="edit-zone" {...form.register("zone")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-plantedAt">Date planted</Label>
|
||||||
|
<Input id="edit-plantedAt" type="date" {...form.register("plantedAt")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-source">Where it came from</Label>
|
||||||
|
<Input id="edit-source" {...form.register("source")} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label htmlFor="edit-notes">Notes</Label>
|
||||||
|
<Textarea id="edit-notes" rows={3} {...form.register("notes")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.1.1",
|
||||||
|
date: "2026-06-15",
|
||||||
|
changes: ["Garden: edit and delete buttons on each plant's page."],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
date: "2026-06-15",
|
date: "2026-06-15",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.1.1";
|
||||||
|
|||||||
Reference in New Issue
Block a user