Files
Moonbase/src/components/garden/garden-grid.tsx
2026-06-15 20:53:03 -05:00

307 lines
12 KiB
TypeScript

"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>
);
}