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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
306
src/components/garden/garden-grid.tsx
Normal file
306
src/components/garden/garden-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/garden/group-log-button.tsx
Normal file
108
src/components/garden/group-log-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
src/components/garden/zone-log-button.tsx
Normal file
98
src/components/garden/zone-log-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user