feat(recipes): full recipe CRUD with photos, print, version history
List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty. Photo upload to S3-compatible storage. Version history. Multi-select grid with bulk delete/visibility. Print view. Delete confirmation dialog.
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Clock, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Recipe = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
baseServings: number;
|
||||
prepMins: number | null;
|
||||
cookMins: number | null;
|
||||
difficulty: "easy" | "medium" | "hard" | null;
|
||||
visibility: "private" | "unlisted" | "public";
|
||||
updatedAt: Date;
|
||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||
};
|
||||
|
||||
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
||||
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
|
||||
|
||||
function SelectableRecipeCard({
|
||||
recipe,
|
||||
selected,
|
||||
selectMode,
|
||||
onToggle,
|
||||
}: {
|
||||
recipe: Recipe;
|
||||
selected: boolean;
|
||||
selectMode: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-xl border bg-card flex flex-col h-full transition-all duration-200",
|
||||
selectMode && "cursor-pointer select-none",
|
||||
selected
|
||||
? "ring-2 ring-primary border-primary shadow-lg shadow-primary/10"
|
||||
: selectMode
|
||||
? "hover:border-muted-foreground/40"
|
||||
: "hover:shadow-md hover:border-muted-foreground/30"
|
||||
)}
|
||||
>
|
||||
{/* Cover image */}
|
||||
<div className="relative aspect-video overflow-hidden bg-muted shrink-0">
|
||||
{cover ? (
|
||||
<img
|
||||
src={getPublicUrl(cover.storageKey)}
|
||||
alt={recipe.title}
|
||||
className={cn(
|
||||
"w-full h-full object-cover transition-transform duration-300",
|
||||
!selectMode && "group-hover:scale-105",
|
||||
selectMode && selected && "brightness-75"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"w-full h-full flex items-center justify-center text-4xl transition-opacity",
|
||||
selectMode && !selected && "opacity-60"
|
||||
)}>
|
||||
🍽️
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection overlay tint */}
|
||||
{selectMode && selected && (
|
||||
<div className="absolute inset-0 bg-primary/20" />
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
{selectMode ? (
|
||||
<div className={cn(
|
||||
"absolute top-2.5 left-2.5 h-6 w-6 rounded-full border-2 flex items-center justify-center transition-all duration-150 shadow-sm",
|
||||
selected
|
||||
? "bg-primary border-primary"
|
||||
: "bg-black/30 border-white/70 group-hover:border-white"
|
||||
)}>
|
||||
{selected && <Check className="h-3.5 w-3.5 text-primary-foreground stroke-[3]" />}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col flex-1 p-3 gap-1">
|
||||
<h3 className={cn(
|
||||
"font-semibold leading-tight line-clamp-2 text-sm transition-colors",
|
||||
!selectMode && "group-hover:text-primary"
|
||||
)}>
|
||||
{recipe.title}
|
||||
</h3>
|
||||
{recipe.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{recipe.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{recipe.baseServings}
|
||||
</span>
|
||||
{totalMins > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{totalMins}m
|
||||
</span>
|
||||
)}
|
||||
{recipe.difficulty && (
|
||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">
|
||||
{recipe.difficulty}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<VisibilityIcon className="h-3 w-3 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (selectMode) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onToggle(recipe.id)}
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
className="h-full"
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/recipes/${recipe.id}`} className="h-full block">
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {
|
||||
const [recipes, setRecipes] = useState(initialRecipes);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) =>
|
||||
prev.size === recipes.length ? new Set() : new Set(recipes.map((r) => r.id))
|
||||
);
|
||||
};
|
||||
|
||||
const exitSelect = () => {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
async function bulkDelete() {
|
||||
if (!confirm(`Delete ${selected.size} recipe${selected.size !== 1 ? "s" : ""}? This cannot be undone.`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/recipes/bulk", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: [...selected] }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
setRecipes((prev) => prev.filter((r) => !selected.has(r.id)));
|
||||
toast.success(`${selected.size} recipe${selected.size !== 1 ? "s" : ""} deleted`);
|
||||
exitSelect();
|
||||
} catch {
|
||||
toast.error("Delete failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkSetVisibility(visibility: "private" | "unlisted" | "public") {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/recipes/bulk", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: [...selected], visibility }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Update failed");
|
||||
setRecipes((prev) =>
|
||||
prev.map((r) => selected.has(r.id) ? { ...r, visibility } : r)
|
||||
);
|
||||
toast.success(`${selected.size} recipe${selected.size !== 1 ? "s" : ""} set to ${visibility}`);
|
||||
exitSelect();
|
||||
} catch {
|
||||
toast.error("Update failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (recipes.length === 0) return null;
|
||||
|
||||
const allSelected = selected.size === recipes.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between min-h-[36px]">
|
||||
{selectMode ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleAll}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{allSelected ? "Deselect all" : "Select all"}
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selected.size > 0
|
||||
? `${selected.size} selected`
|
||||
: "Click cards to select"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button
|
||||
variant={selectMode ? "ghost" : "ghost"}
|
||||
size="sm"
|
||||
onClick={selectMode ? exitSelect : () => setSelectMode(true)}
|
||||
className={cn("gap-1.5", selectMode && "text-muted-foreground")}
|
||||
>
|
||||
{selectMode ? (
|
||||
<><X className="h-4 w-4" />Cancel</>
|
||||
) : (
|
||||
<><ListChecks className="h-4 w-4" />Select</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start">
|
||||
{recipes.map((recipe) => (
|
||||
<SelectableRecipeCard
|
||||
key={recipe.id}
|
||||
recipe={recipe}
|
||||
selected={selected.has(recipe.id)}
|
||||
selectMode={selectMode}
|
||||
onToggle={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating bulk action bar */}
|
||||
{selectMode && selected.size > 0 && (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 duration-200">
|
||||
<div className="flex items-center gap-2 bg-popover border shadow-2xl rounded-2xl px-5 py-3">
|
||||
<span className="text-sm font-semibold tabular-nums mr-1">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={busy}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Visibility
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" side="top">
|
||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
|
||||
<Globe className="h-4 w-4 mr-2 text-green-500" /> Make public
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
|
||||
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> Make unlisted
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
|
||||
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> Make private
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => { void bulkDelete(); }}
|
||||
disabled={busy}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user