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:
Arnaud
2026-07-01 08:10:11 +02:00
parent fa2d797918
commit 84d6cfeb07
45 changed files with 5300 additions and 0 deletions
@@ -0,0 +1,220 @@
"use client";
import { useState, useEffect } from "react";
import { ShoppingCart, Loader2, Plus, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
type Ingredient = {
rawName: string;
quantity?: string | number | null;
unit?: string | null;
};
type ShoppingList = { id: string; name: string };
function scaleQty(quantity: string | number | null | undefined, scale: number): string | undefined {
if (!quantity) return undefined;
const n = typeof quantity === "number" ? quantity : parseFloat(quantity);
if (isNaN(n)) return String(quantity);
const scaled = n * scale;
return scaled % 1 === 0 ? String(scaled) : scaled.toFixed(2).replace(/\.?0+$/, "");
}
export function AddToShoppingListButton({
recipeId,
recipeTitle,
baseServings,
ingredients,
}: {
recipeId: string;
recipeTitle: string;
baseServings: number;
ingredients: Ingredient[];
}) {
const [open, setOpen] = useState(false);
const [lists, setLists] = useState<ShoppingList[]>([]);
const [loadingLists, setLoadingLists] = useState(false);
const [mode, setMode] = useState<"existing" | "new">("existing");
const [selectedListId, setSelectedListId] = useState<string>("");
const [newListName, setNewListName] = useState(`${recipeTitle} shopping`);
const [servings, setServings] = useState(baseServings);
const [adding, setAdding] = useState(false);
useEffect(() => {
if (!open) return;
setLoadingLists(true);
fetch("/api/v1/shopping-lists")
.then((r) => r.json() as Promise<ShoppingList[]>)
.then((data) => {
setLists(data);
if (data.length > 0) {
setMode("existing");
setSelectedListId(data[0]!.id);
} else {
setMode("new");
}
})
.catch(() => setMode("new"))
.finally(() => setLoadingLists(false));
}, [open]);
const scale = servings / baseServings;
const items = ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: scaleQty(ing.quantity, scale),
unit: ing.unit ?? undefined,
}));
async function handleAdd() {
setAdding(true);
try {
let listId = selectedListId;
if (mode === "new") {
const res = await fetch("/api/v1/shopping-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newListName.trim() || recipeTitle }),
});
if (!res.ok) { toast.error("Failed to create list"); return; }
const created = await res.json() as { id: string };
listId = created.id;
}
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
if (!res.ok) { toast.error("Failed to add ingredients"); return; }
toast.success(`${items.length} ingredients added to list`);
setOpen(false);
} finally {
setAdding(false);
}
}
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<ShoppingCart className="h-4 w-4" />
Add to list
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5 text-primary" />
Add to shopping list
</DialogTitle>
<DialogDescription>
Add the ingredients of <strong>{recipeTitle}</strong> to a shopping list.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Servings scaler */}
<div className="flex items-center gap-3">
<Label className="shrink-0">Servings</Label>
<div className="flex items-center gap-2">
<Button
type="button" variant="outline" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => Math.max(1, s - 1))}
disabled={servings <= 1}
></Button>
<span className="w-8 text-center font-medium">{servings}</span>
<Button
type="button" variant="outline" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => s + 1)}
>+</Button>
</div>
{scale !== 1 && (
<span className="text-xs text-muted-foreground">({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} scaled)</span>
)}
</div>
<Separator />
{/* List picker */}
{loadingLists ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading your lists
</div>
) : (
<div className="space-y-3">
{lists.length > 0 && (
<RadioGroup value={mode} onValueChange={(v: string) => setMode(v as "existing" | "new")}>
<div className="flex items-center gap-2">
<RadioGroupItem value="existing" id="mode-existing" />
<Label htmlFor="mode-existing">Add to existing list</Label>
</div>
{mode === "existing" && (
<div className="ml-6 space-y-1.5">
{lists.map((list) => (
<button
key={list.id}
onClick={() => setSelectedListId(list.id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center justify-between ${
selectedListId === list.id
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
}`}
>
{list.name}
{selectedListId === list.id && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<RadioGroupItem value="new" id="mode-new" />
<Label htmlFor="mode-new">Create new list</Label>
</div>
</RadioGroup>
)}
{(mode === "new" || lists.length === 0) && (
<div className="space-y-1.5 ml-6">
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder="List name"
/>
</div>
)}
</div>
)}
<Separator />
<p className="text-xs text-muted-foreground">{items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.</p>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)} disabled={adding}>Cancel</Button>
<Button onClick={handleAdd} disabled={adding || loadingLists || (mode === "existing" && !selectedListId)}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "Adding…" : "Add ingredients"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}