307 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|