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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user