feat: garden plants crud (task 72)
- perenual species lookup with 24h db cache (api key optional) - /api/garden/species-search route handler - createPlant / updatePlant / deletePlant server actions - addPlantImage / removePlantImage / setPrimaryImage actions (max 10) - listPlants query grouped by container with last-watered subquery - getPlant query with care log summary and active schedules - plant-form client component with species autofill and image upload - plant-list server component grouped by container with unassigned bucket - plant-detail client component with info/gallery/care tabs - species-search debounced combobox component - /garden/plants/new, /[id], /[id]/edit pages - garden.plant entity registered with search and share support - /garden page updated to containers/plants tabs (default: containers)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import type { PlantListItemDto } from "../server/queries";
|
||||
|
||||
type Props = {
|
||||
plants: PlantListItemDto[];
|
||||
};
|
||||
|
||||
function daysAgo(isoString: string): string {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const days = Math.floor(diff / 86_400_000);
|
||||
if (days === 0) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function healthBadgeClass(status: string): string {
|
||||
if (status === "healthy") return "badge-success";
|
||||
if (status === "sick") return "badge-danger";
|
||||
return "badge-warning";
|
||||
}
|
||||
|
||||
export function PlantList({ plants }: Props) {
|
||||
if (plants.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>
|
||||
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
|
||||
Add your first plant
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = new Map<string | null, { name: string | null; items: PlantListItemDto[] }>();
|
||||
for (const plant of plants) {
|
||||
const key = plant.containerId;
|
||||
if (!groups.has(key)) groups.set(key, { name: plant.containerName, items: [] });
|
||||
groups.get(key)!.items.push(plant);
|
||||
}
|
||||
|
||||
const entries = [...groups.entries()].sort(([a], [b]) => {
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--ink-mute)]">
|
||||
{plants.length} {plants.length === 1 ? "plant" : "plants"}
|
||||
</span>
|
||||
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
|
||||
Add plant
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{entries.map(([containerId, group]) => (
|
||||
<div key={containerId ?? "_unassigned"}>
|
||||
<h3 className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wider mb-2">
|
||||
{group.name ?? "Unassigned"}
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{group.items.map((plant) => (
|
||||
<Link
|
||||
key={plant.id}
|
||||
href={`/garden/plants/${plant.id}`}
|
||||
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{plant.primaryImageUrl ? (
|
||||
<img
|
||||
src={plant.primaryImageUrl}
|
||||
alt=""
|
||||
className="w-12 h-12 rounded-lg object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-[var(--surface-2)] shrink-0 flex items-center justify-center text-xl">
|
||||
🌱
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{plant.name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
|
||||
{plant.healthStatus}
|
||||
</span>
|
||||
{plant.lastWateredAt && (
|
||||
<span className="text-xs text-[var(--ink-mute)]">
|
||||
Watered {daysAgo(plant.lastWateredAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plant.hasOverdueCare && (
|
||||
<p className="text-xs text-amber-600 mt-0.5">Care overdue</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user