v0.5.1 — Reminders, plant groups, location views, Wikipedia photos, Docker deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-06-15 20:53:03 -05:00
parent e3f6a8ec92
commit 04dec2a198
32 changed files with 1988 additions and 181 deletions

View File

@@ -14,24 +14,32 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Plus, Leaf } from "lucide-react";
import { Plus, Leaf, MapPin, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
type Group = { id: string; name: string };
const BLANK = {
commonName: "",
species: "",
variety: "",
category: "CANOPY_TREE" as PlantCategory,
zone: "",
plantedAt: "",
source: "",
notes: "",
groupId: "",
newGroupName: "",
};
export function AddPlantButton() {
export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; zones?: string[] }) {
const [open, setOpen] = useState(false);
const [fields, setFields] = useState(BLANK);
const [selectedZones, setSelectedZones] = useState<string[]>([]);
const [newZoneName, setNewZoneName] = useState("");
const [addingZone, setAddingZone] = useState(false);
const [creatingGroup, setCreatingGroup] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [suggestions, setSuggestions] = useState<PlantSuggestion[]>([]);
@@ -39,10 +47,27 @@ export function AddPlantButton() {
const router = useRouter();
const { toast } = useToast();
// All zones including newly typed ones not yet in the DB
const allZones = Array.from(new Set([...zones, ...selectedZones]));
function set(key: keyof typeof BLANK, value: string) {
setFields((f) => ({ ...f, [key]: value }));
}
function toggleZone(zone: string) {
setSelectedZones((prev) =>
prev.includes(zone) ? prev.filter((z) => z !== zone) : [...prev, zone]
);
}
function addNewZone() {
const name = newZoneName.trim();
if (!name) return;
if (!selectedZones.includes(name)) setSelectedZones((prev) => [...prev, name]);
setNewZoneName("");
setAddingZone(false);
}
function onNameChange(value: string) {
set("commonName", value);
const results = searchPlants(value);
@@ -65,9 +90,13 @@ export function AddPlantButton() {
function handleClose() {
setOpen(false);
setFields(BLANK);
setSelectedZones([]);
setNewZoneName("");
setAddingZone(false);
setError("");
setSuggestions([]);
setShowSuggestions(false);
setCreatingGroup(false);
}
async function handleSubmit(e: React.FormEvent) {
@@ -78,30 +107,57 @@ export function AddPlantButton() {
}
setError("");
setBusy(true);
const res = await fetch("/api/plants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
commonName: fields.commonName.trim(),
species: fields.species.trim() || undefined,
variety: fields.variety.trim() || undefined,
category: fields.category,
zone: fields.zone.trim() || undefined,
plantedAt: fields.plantedAt || undefined,
source: fields.source.trim() || undefined,
notes: fields.notes.trim() || undefined,
}),
});
// Create group first if needed.
let groupId = fields.groupId || undefined;
if (fields.newGroupName.trim()) {
const gRes = await fetch("/api/plant-groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: fields.newGroupName.trim() }),
});
if (gRes.ok) groupId = (await gRes.json()).id;
}
const base = {
commonName: fields.commonName.trim(),
species: fields.species.trim() || undefined,
variety: fields.variety.trim() || undefined,
category: fields.category,
plantedAt: fields.plantedAt || undefined,
source: fields.source.trim() || undefined,
notes: fields.notes.trim() || undefined,
groupId,
};
// Create one plant per selected zone (or one with no zone if none selected).
const zonesToCreate = selectedZones.length > 0 ? selectedZones : [undefined];
const results = await Promise.all(
zonesToCreate.map((zone) =>
fetch("/api/plants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...base, zone }),
})
)
);
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save plant.", variant: "destructive" });
const failed = results.filter((r) => !r.ok);
if (failed.length > 0) {
toast({ title: "Error", description: "Could not save one or more plants.", variant: "destructive" });
return;
}
const plant = await res.json();
toast({ title: "Plant added!", description: `${fields.commonName} is now in your garden.` });
const count = zonesToCreate.length;
toast({
title: "Plant added!",
description: count > 1
? `Added ${fields.commonName} to ${count} locations.`
: `${fields.commonName} is now in your garden.`,
});
handleClose();
router.push(`/garden/${plant.id}`);
router.refresh();
}
@@ -174,13 +230,8 @@ export function AddPlantButton() {
<div className="space-y-1.5">
<Label>Category *</Label>
<Select
value={fields.category}
onValueChange={(v) => set("category", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<Select value={fields.category} onValueChange={(v) => set("category", v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
@@ -203,14 +254,76 @@ export function AddPlantButton() {
/>
</div>
{/* Multi-location picker */}
<div className="col-span-2 space-y-1.5">
<Label htmlFor="zone">Location on your property</Label>
<Input
id="zone"
placeholder="Back yard north fence, Front guild, Raised bed #1…"
value={fields.zone}
onChange={(e) => set("zone", e.target.value)}
/>
<Label>
Location(s) on your property
<span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span>
</Label>
{/* Selected zones as chips */}
{selectedZones.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedZones.map((z) => (
<Badge key={z} variant="secondary" className="gap-1 pr-1">
<MapPin className="h-3 w-3" />
{z}
<button
type="button"
onClick={() => toggleZone(z)}
className="ml-0.5 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{/* Existing zones as checkboxes */}
{allZones.length > 0 && (
<div className="border rounded-md divide-y max-h-36 overflow-y-auto">
{allZones.map((z) => (
<label
key={z}
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm"
>
<input
type="checkbox"
checked={selectedZones.includes(z)}
onChange={() => toggleZone(z)}
className="accent-[hsl(var(--leaf))]"
/>
{z}
</label>
))}
</div>
)}
{/* Add new zone */}
{addingZone ? (
<div className="flex gap-2 mt-1">
<Input
placeholder="e.g. Front bed, Back fence guild…"
value={newZoneName}
onChange={(e) => setNewZoneName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }}
autoFocus
/>
<Button type="button" size="sm" onClick={addNewZone}>Add</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingZone(false); setNewZoneName(""); }}>
Cancel
</Button>
</div>
) : (
<button
type="button"
onClick={() => setAddingZone(true)}
className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1"
>
+ Add new location
</button>
)}
</div>
<div className="space-y-1.5">
@@ -224,13 +337,50 @@ export function AddPlantButton() {
</div>
<div className="space-y-1.5">
<Label htmlFor="source">Where it came from</Label>
<Input
id="source"
placeholder="Jung's Nursery, from seed…"
value={fields.source}
onChange={(e) => set("source", e.target.value)}
/>
<Label>Where did it come from?</Label>
<Select value={fields.source} onValueChange={(v) => set("source", v)}>
<SelectTrigger><SelectValue placeholder="Select one…" /></SelectTrigger>
<SelectContent>
<SelectItem value="We planted it">We planted it</SelectItem>
<SelectItem value="Already here">Already here when we moved in</SelectItem>
<SelectItem value="Gift or trade">Gift or trade</SelectItem>
<SelectItem value="Self-seeded">Self-seeded</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Group (optional)</Label>
{!creatingGroup ? (
<Select
value={fields.groupId}
onValueChange={(v) => {
if (v === "__new__") { setCreatingGroup(true); set("groupId", ""); }
else { set("groupId", v); }
}}
>
<SelectTrigger><SelectValue placeholder="No group" /></SelectTrigger>
<SelectContent>
<SelectItem value="">No group</SelectItem>
{groups.map((g) => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
<SelectItem value="__new__">+ Create new group</SelectItem>
</SelectContent>
</Select>
) : (
<div className="flex gap-2">
<Input
placeholder="New group name (e.g. Irises)"
value={fields.newGroupName}
onChange={(e) => set("newGroupName", e.target.value)}
autoFocus
/>
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingGroup(false); set("newGroupName", ""); }}>
Cancel
</Button>
</div>
)}
</div>
<div className="col-span-2 space-y-1.5">
@@ -248,7 +398,7 @@ export function AddPlantButton() {
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
<Button type="submit" disabled={busy}>
{busy ? "Saving…" : "Add plant"}
{busy ? "Saving…" : selectedZones.length > 1 ? `Add to ${selectedZones.length} locations` : "Add plant"}
</Button>
</DialogFooter>
</form>

View File

@@ -37,8 +37,10 @@ function toDateInput(d: Date | null): string {
return new Date(d).toISOString().split("T")[0];
}
export function EditPlantButton({ plant }: { plant: Plant }) {
export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Plant; zones?: string[]; groups?: { id: string; name: string }[] }) {
const [open, setOpen] = useState(false);
const [creatingZone, setCreatingZone] = useState(false);
const [newZoneName, setNewZoneName] = useState("");
const router = useRouter();
const { toast } = useToast();
@@ -57,6 +59,7 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
});
async function onSubmit(values: FormValues) {
if (newZoneName.trim()) values.zone = newZoneName.trim();
const res = await fetch(`/api/plants/${plant.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -123,8 +126,39 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
</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")} />
<Label>Location on your property</Label>
{!creatingZone ? (
<Select
defaultValue={plant.zone ?? ""}
onValueChange={(v) => {
if (v === "__new__") { setCreatingZone(true); form.setValue("zone", ""); }
else { form.setValue("zone", v); setNewZoneName(""); }
}}
>
<SelectTrigger>
<SelectValue placeholder="No location" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No location</SelectItem>
{zones.map((z) => (
<SelectItem key={z} value={z}>{z}</SelectItem>
))}
<SelectItem value="__new__">+ Add new location</SelectItem>
</SelectContent>
</Select>
) : (
<div className="flex gap-2">
<Input
placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
value={newZoneName}
onChange={(e) => setNewZoneName(e.target.value)}
autoFocus
/>
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingZone(false); setNewZoneName(""); }}>
Cancel
</Button>
</div>
)}
</div>
<div className="space-y-1.5">
@@ -133,8 +167,21 @@ export function EditPlantButton({ plant }: { plant: Plant }) {
</div>
<div className="space-y-1.5">
<Label htmlFor="edit-source">Where it came from</Label>
<Input id="edit-source" {...form.register("source")} />
<Label>Where did it come from?</Label>
<Select
defaultValue={plant.source ?? ""}
onValueChange={(v) => form.setValue("source", v)}
>
<SelectTrigger>
<SelectValue placeholder="Select one…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="We planted it">We planted it</SelectItem>
<SelectItem value="Already here">Already here when we moved in</SelectItem>
<SelectItem value="Gift or trade">Gift or trade</SelectItem>
<SelectItem value="Self-seeded">Self-seeded</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-2 space-y-1.5">

View File

@@ -0,0 +1,306 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { GroupLogButton } from "@/components/garden/group-log-button";
import { ZoneLogButton } from "@/components/garden/zone-log-button";
type Plant = {
id: string;
commonName: string;
variety: string | null;
species: string | null;
category: string;
zone: string | null;
plantedAt: Date | null;
imageUrl: string | null;
group: { id: string; name: string } | null;
_count: { logs: number };
logs: { date: Date; type: string }[];
};
type Group = {
id: string;
name: string;
_count: { plants: number };
};
type ViewMode = "type" | "location";
// Cluster plants that share the same commonName + variety into one row.
function clusterByVariety(plants: Plant[]) {
const map = new Map<string, Plant[]>();
for (const p of plants) {
const key = `${p.commonName}||${p.variety ?? ""}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
}
return Array.from(map.values());
}
export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[] }) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [view, setView] = useState<ViewMode>("type");
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
if (plants.length === 0) {
return (
<div className="text-center py-16 text-muted-foreground">
<Leaf className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plants yet</p>
<p className="text-sm mt-1">Add your first plant to start building your food forest registry.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit">
<Button size="sm" variant={view === "type" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("type")}>
By type
</Button>
<Button size="sm" variant={view === "location" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("location")}>
By location
</Button>
</div>
{view === "type" ? (
<TypeView plants={plants} groups={groups} expanded={expanded} toggle={toggle} />
) : (
<LocationView plants={plants} expanded={expanded} toggle={toggle} />
)}
</div>
);
}
function TypeView({ plants, groups, expanded, toggle }: {
plants: Plant[];
groups: Group[];
expanded: Set<string>;
toggle: (id: string) => void;
}) {
const ungrouped = plants.filter((p) => !p.group);
const grouped = groups.map((g) => ({
...g,
plants: plants.filter((p) => p.group?.id === g.id),
})).filter((g) => g.plants.length > 0);
return (
<div className="space-y-6">
{grouped.map((g) => {
const isOpen = expanded.has(g.id);
const clusters = clusterByVariety(g.plants);
return (
<div key={g.id} className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggle(g.id)}
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<Users className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{g.name}</span>
<span className="text-sm text-muted-foreground">· {g.plants.length} {g.plants.length === 1 ? "plant" : "plants"}</span>
</button>
<GroupLogButton groupId={g.id} groupName={g.name} />
</div>
{isOpen ? (
<div className="pl-6 space-y-2">
{clusters.map((cluster) => (
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
) : (
<button onClick={() => toggle(g.id)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{clusters.map((c) => c[0].variety ? `${c[0].commonName} (${c[0].variety})` : c[0].commonName).join(", ")}
</button>
)}
</div>
);
})}
{ungrouped.length > 0 && (
<div className="space-y-2">
{grouped.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Other plants</p>}
<div className="space-y-2">
{clusterByVariety(ungrouped).map((cluster) => (
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
</div>
)}
</div>
);
}
// A single variety that may span multiple locations.
function VarietyRow({ plants, expanded, toggle }: {
plants: Plant[];
expanded: Set<string>;
toggle: (id: string) => void;
}) {
const rep = plants[0];
const key = `variety||${rep.commonName}||${rep.variety ?? ""}`;
const isOpen = expanded.has(key);
// Collapsible row with location chips.
return (
<div className="space-y-2">
<button
onClick={() => toggle(key)}
className="flex items-start gap-2 text-left hover:text-foreground transition-colors w-full"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />}
<div className="flex-1 min-w-0">
<span className="font-medium text-sm">
{rep.commonName}
{rep.variety && <span className="text-muted-foreground font-normal"> · {rep.variety}</span>}
</span>
{!isOpen && (
<div className="flex flex-wrap gap-1 mt-1">
{plants.map((p) => (
<Badge key={p.id} variant="outline" className="text-xs gap-1">
<MapPin className="h-2.5 w-2.5" />
{p.zone ?? "No location"}
</Badge>
))}
</div>
)}
</div>
</button>
{isOpen && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
{plants.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
)}
</div>
);
}
function LocationView({ plants, expanded, toggle }: {
plants: Plant[];
expanded: Set<string>;
toggle: (id: string) => void;
}) {
const zones = Array.from(new Set(plants.filter((p) => p.zone).map((p) => p.zone!))).sort();
const noZone = plants.filter((p) => !p.zone);
return (
<div className="space-y-6">
{zones.map((zone) => {
const zonePlants = plants.filter((p) => p.zone === zone);
const isOpen = expanded.has(zone);
return (
<div key={zone} className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => toggle(zone)}
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{zone}</span>
<span className="text-sm text-muted-foreground">· {zonePlants.length} {zonePlants.length === 1 ? "plant" : "plants"}</span>
</button>
<ZoneLogButton zone={zone} plantCount={zonePlants.length} />
</div>
{isOpen ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
{zonePlants.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
) : (
<button onClick={() => toggle(zone)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{zonePlants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
</button>
)}
</div>
);
})}
{noZone.length > 0 && (
<div className="space-y-2">
{zones.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{noZone.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
</div>
)}
</div>
);
}
function PlantCard({ plant }: { plant: Plant }) {
return (
<Link href={`/garden/${plant.id}`}>
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer overflow-hidden">
{plant.imageUrl && (
<div className="h-36 w-full overflow-hidden bg-muted">
<img
src={plant.imageUrl}
alt={plant.commonName}
className="h-full w-full object-cover"
/>
</div>
)}
{!plant.imageUrl && (
<div className="h-36 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
<Leaf className="h-10 w-10 text-[hsl(var(--leaf)/.3)]" />
</div>
)}
<CardContent className="pt-3 pb-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium truncate">
{plant.commonName}
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
</p>
{plant.species && <p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>}
</div>
<Badge variant="secondary" className="shrink-0 text-xs">
{CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]}
</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{plant.zone && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{plant.zone}</span>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-1.5">
<CalendarDays className="h-3 w-3 shrink-0" />
<span>Planted {formatDate(plant.plantedAt)}</span>
</div>
)}
{plant.logs[0] && (
<div className="flex items-center gap-1.5">
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
<span>Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}</span>
</div>
)}
</div>
<div className="pt-1 text-xs text-muted-foreground/60">
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
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 { ClipboardList } from "lucide-react";
import { LOG_TYPE_LABELS } from "@/lib/garden/categories";
const LOG_TYPES = ["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT"] as const;
export function GroupLogButton({ groupId, groupName }: { groupId: string; groupName: string }) {
const [open, setOpen] = useState(false);
const [type, setType] = useState("CARE");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const res = await fetch(`/api/plant-groups/${groupId}/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, notes: notes.trim() || undefined }),
});
setBusy(false);
if (!res.ok) {
toast({ title: "Error", description: "Could not save log entry.", variant: "destructive" });
return;
}
const data = await res.json();
toast({ title: "Logged!", description: `Added to all ${data.count} ${groupName.toLowerCase()}.` });
setNotes("");
setOpen(false);
router.refresh();
}
return (
<>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={(e) => { e.stopPropagation(); setOpen(true); }}
>
<ClipboardList className="h-3.5 w-3.5 mr-1" />
Log all
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Log for all {groupName}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOG_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{LOG_TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea
rows={3}
placeholder="What did you do?"
value={notes}
onChange={(e) => setNotes(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button
type="submit"
disabled={busy}
className="bg-[hsl(var(--leaf))] hover:bg-[hsl(var(--leaf)/.85)]"
>
{busy ? "Saving…" : "Save log"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
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 { ClipboardList } from "lucide-react";
import { LOG_TYPE_LABELS } from "@/lib/garden/categories";
const LOG_TYPES = ["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT"] as const;
export function ZoneLogButton({ zone, plantCount }: { zone: string; plantCount: number }) {
const [open, setOpen] = useState(false);
const [type, setType] = useState("CARE");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const res = await fetch(`/api/zones/${encodeURIComponent(zone)}/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, notes: notes.trim() || undefined }),
});
setBusy(false);
if (!res.ok) {
toast({ title: "Error", description: "Could not save log entry.", variant: "destructive" });
return;
}
const data = await res.json();
toast({ title: "Logged!", description: `Added to all ${data.count} plants in ${zone}.` });
setNotes("");
setOpen(false);
router.refresh();
}
return (
<>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={(e) => { e.stopPropagation(); setOpen(true); }}
>
<ClipboardList className="h-3.5 w-3.5 mr-1" />
Log all
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Log for all plants in {zone}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{LOG_TYPES.map((t) => (
<SelectItem key={t} value={t}>{LOG_TYPE_LABELS[t]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea
rows={3}
placeholder="What did you do?"
value={notes}
onChange={(e) => setNotes(e.target.value)}
autoFocus
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy} className="bg-[hsl(var(--leaf))] hover:bg-[hsl(var(--leaf)/.85)]">
{busy ? "Saving…" : "Save log"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}