Files
Epicure/apps/web/components/recipe/adapt-recipe-button.tsx
T
2026-07-01 11:10:37 +02:00

195 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Wand2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
type Ingredient = { rawName: string };
export function AdaptRecipeButton({
recipeId,
ingredients,
}: {
recipeId: string;
ingredients: Ingredient[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [extraConstraints, setExtraConstraints] = useState("");
const [adapting, setAdapting] = useState(false);
const [adaptationNotes, setAdaptationNotes] = useState("");
function toggleExclude(name: string) {
setExcluded((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function reset() {
setExcluded(new Set());
setExtraConstraints("");
setAdaptationNotes("");
}
async function handleAdapt() {
if (excluded.size === 0 && !extraConstraints.trim()) {
toast.error("Select at least one ingredient to exclude or add a constraint");
return;
}
setAdapting(true);
setAdaptationNotes("");
try {
const res = await fetch(`/api/v1/ai/adapt/${recipeId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
excludeIngredients: Array.from(excluded),
extraConstraints: extraConstraints.trim() || undefined,
}),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed to adapt recipe");
return;
}
const { id, adaptationNotes: notes } = await res.json() as { id: string; adaptationNotes: string };
setAdaptationNotes(notes);
toast.success("Adapted recipe saved as draft");
setOpen(false);
reset();
router.push(`/recipes/${id}/edit`);
} finally {
setAdapting(false);
}
}
const hasConstraints = excluded.size > 0 || extraConstraints.trim().length > 0;
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Wand2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>Adapt</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5 text-primary" />
Adapt this recipe
</DialogTitle>
<DialogDescription>
Tap ingredients to exclude them. AI will find the best substitutes while preserving the dish.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{/* Ingredient chips */}
<div className="space-y-2">
<Label>
Exclude ingredients
{excluded.size > 0 && (
<span className="ml-2 text-xs text-destructive font-normal">
{excluded.size} excluded
</span>
)}
</Label>
<div className="flex flex-wrap gap-2">
{ingredients.map((ing, idx) => {
const isExcluded = excluded.has(ing.rawName);
return (
<button
key={`${ing.rawName}-${idx}`}
type="button"
onClick={() => toggleExclude(ing.rawName)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm border transition-all",
isExcluded
? "bg-destructive/10 border-destructive/40 text-destructive line-through"
: "bg-muted border-transparent hover:border-border hover:bg-accent"
)}
>
{isExcluded && <X className="h-3 w-3 shrink-0" />}
{ing.rawName}
</button>
);
})}
</div>
</div>
{/* Free-text constraints */}
<div className="space-y-2">
<Label htmlFor="extra-constraints">
Additional constraints
<span className="ml-2 text-xs text-muted-foreground font-normal">optional</span>
</Label>
<Textarea
id="extra-constraints"
value={extraConstraints}
onChange={(e) => setExtraConstraints(e.target.value)}
placeholder="e.g. Make it vegan, lower the calories, use pantry staples only, gluten-free…"
rows={2}
disabled={adapting}
/>
</div>
{adapting && (
<p className="text-sm text-muted-foreground">
Adapting recipe this may take 2030 seconds
</p>
)}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => { setOpen(false); reset(); }}
disabled={adapting}
>
Cancel
</Button>
{excluded.size > 0 && (
<Button variant="ghost" size="sm" onClick={() => setExcluded(new Set())} disabled={adapting}>
Clear
</Button>
)}
<Button onClick={handleAdapt} disabled={adapting || !hasConstraints}>
{adapting
? <><Loader2 className="h-4 w-4 animate-spin" />Adapting</>
: <><Wand2 className="h-4 w-4" />Adapt recipe</>
}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}