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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user