d8dc0aa465
AI adapt/variations flows had no catch on their fetch calls, so a network failure surfaced as nothing happening rather than an error toast. They also unconditionally persisted the AI's output even when it was identical to the source recipe, and the adapt route never recorded a recipeVariations row (only plain forks did) or enforced the per-tier recipe-count limit other create paths already check. v0.39.0
292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useRouter } from "next/navigation";
|
|
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
|
import { Sparkles, Loader2, ChevronRight, Replace } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { isRecipeUnchanged } from "@/lib/recipe-diff";
|
|
|
|
type Variation = {
|
|
title: string;
|
|
description: string;
|
|
changedIngredients: Array<{ original: string; replacement: string; note?: string }>;
|
|
changedSteps?: Array<{ stepNumber: number; change: string }>;
|
|
dietaryTags?: Record<string, boolean>;
|
|
};
|
|
|
|
type Ingredient = {
|
|
rawName: string;
|
|
quantity?: string | number | null;
|
|
unit?: string | null;
|
|
note?: string | null;
|
|
order: number;
|
|
};
|
|
|
|
type Step = {
|
|
instruction: string;
|
|
timerSeconds?: number | null;
|
|
order: number;
|
|
};
|
|
|
|
export function VariationsDialog({
|
|
recipeId,
|
|
baseServings,
|
|
difficulty,
|
|
prepMins,
|
|
cookMins,
|
|
ingredients,
|
|
steps,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
recipeId: string;
|
|
baseServings: number;
|
|
difficulty?: string | null;
|
|
prepMins?: number | null;
|
|
cookMins?: number | null;
|
|
ingredients: Ingredient[];
|
|
steps: Step[];
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const t = useTranslations("recipe");
|
|
const tv = useTranslations("ai.variations");
|
|
const router = useRouter();
|
|
const [generating, setGenerating] = useState(false);
|
|
const [applying, setApplying] = useState<number | null>(null);
|
|
const [variations, setVariations] = useState<Variation[]>([]);
|
|
const [directions, setDirections] = useState("");
|
|
|
|
async function generate() {
|
|
setGenerating(true);
|
|
setVariations([]);
|
|
try {
|
|
const res = await fetch(`/api/v1/ai/variations/${recipeId}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ count: 3, directions: directions.trim() || undefined }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json() as { error?: string };
|
|
toast.error(err.error ?? tv("error"));
|
|
return;
|
|
}
|
|
const data = await res.json() as { variations: Variation[] };
|
|
setVariations(data.variations);
|
|
} catch {
|
|
toast.error(tv("error"));
|
|
} finally {
|
|
setGenerating(false);
|
|
}
|
|
}
|
|
|
|
async function apply(variation: Variation, index: number) {
|
|
setApplying(index);
|
|
try {
|
|
const updatedIngredients = ingredients.map((ing) => {
|
|
const change = variation.changedIngredients.find(
|
|
(c) => ing.rawName.toLowerCase().includes(c.original.toLowerCase())
|
|
);
|
|
if (change) {
|
|
return { ...ing, rawName: change.replacement, note: change.note ?? ing.note };
|
|
}
|
|
return ing;
|
|
});
|
|
|
|
const updatedSteps = steps.map((step, i) => {
|
|
const change = variation.changedSteps?.find((c) => c.stepNumber === i + 1);
|
|
if (change) {
|
|
return { ...step, instruction: `${step.instruction} [Variation: ${change.change}]` };
|
|
}
|
|
return step;
|
|
});
|
|
|
|
// The AI's changedIngredients/changedSteps are matched against the
|
|
// original by substring — if none of them actually matched anything
|
|
// (free-text vs. free-text mismatch), this "variation" is really just
|
|
// a byte-for-byte copy. Saving it would silently create a duplicate
|
|
// recipe with nothing different from the original.
|
|
if (isRecipeUnchanged({ baseServings, ingredients, steps }, { baseServings, ingredients: updatedIngredients, steps: updatedSteps })) {
|
|
toast.error(tv("noChanges"));
|
|
return;
|
|
}
|
|
|
|
const saveRes = await fetch("/api/v1/recipes", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: variation.title,
|
|
description: variation.description,
|
|
baseServings,
|
|
difficulty,
|
|
prepMins,
|
|
cookMins,
|
|
dietaryTags: variation.dietaryTags ?? {},
|
|
visibility: "private",
|
|
aiGenerated: true,
|
|
ingredients: updatedIngredients.map((ing, i) => ({
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity ? String(ing.quantity) : undefined,
|
|
unit: ing.unit ?? undefined,
|
|
note: ing.note ?? undefined,
|
|
order: i,
|
|
})),
|
|
steps: updatedSteps.map((step, i) => ({
|
|
instruction: step.instruction,
|
|
timerSeconds: step.timerSeconds ?? undefined,
|
|
order: i,
|
|
})),
|
|
}),
|
|
});
|
|
|
|
if (!saveRes.ok) {
|
|
toast.error(tv("saveError"));
|
|
return;
|
|
}
|
|
|
|
const saved = await saveRes.json() as { id: string };
|
|
toast.success(tv("success"));
|
|
onOpenChange(false);
|
|
router.push(`/recipes/${saved.id}/edit`);
|
|
} catch {
|
|
toast.error(tv("saveError"));
|
|
} finally {
|
|
setApplying(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-primary" />
|
|
{tv("title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{tv("description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{variations.length === 0 ? (
|
|
<div className="flex flex-col gap-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="directions">{tv("directions")} <span className="text-muted-foreground font-normal">({tv("optional")})</span></Label>
|
|
<Textarea
|
|
id="directions"
|
|
placeholder={t("variationsDirectionsPlaceholder")}
|
|
value={directions}
|
|
onChange={(e) => setDirections(e.target.value)}
|
|
disabled={generating}
|
|
rows={3}
|
|
maxLength={500}
|
|
/>
|
|
</div>
|
|
<Button onClick={generate} disabled={generating} size="lg" className="self-center">
|
|
{generating ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{tv("generating")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Sparkles className="h-4 w-4" />
|
|
{tv("generate")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<FakeProgressBar active={generating} durationMs={22000} label={generating ? tv("generating") : undefined} />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{variations.map((v, i) => (
|
|
<div key={i} className="rounded-lg border p-4 space-y-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-1 flex-1">
|
|
<h3 className="font-semibold">{v.title}</h3>
|
|
<p className="text-sm text-muted-foreground">{v.description}</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => apply(v, i)}
|
|
disabled={applying !== null}
|
|
>
|
|
{applying === i ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
)}
|
|
{tv("apply")}
|
|
</Button>
|
|
</div>
|
|
|
|
{v.changedIngredients.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{tv("ingredientChanges")}</p>
|
|
<div className="space-y-1">
|
|
{v.changedIngredients.map((c, j) => (
|
|
<div key={j} className="flex items-center gap-2 text-sm">
|
|
<Replace className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
<span className="text-muted-foreground line-through">{c.original}</span>
|
|
<span>→</span>
|
|
<span>{c.replacement}</span>
|
|
{c.note && <span className="text-muted-foreground text-xs">({c.note})</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{v.changedSteps && v.changedSteps.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{tv("stepChanges")}</p>
|
|
<div className="space-y-1">
|
|
{v.changedSteps.map((c, j) => (
|
|
<div key={j} className="flex gap-2 text-sm">
|
|
<Badge variant="outline" className="text-xs shrink-0">{tv("stepLabel", { n: c.stepNumber })}</Badge>
|
|
<span className="text-muted-foreground">{c.change}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{v.dietaryTags && Object.entries(v.dietaryTags).some(([, val]) => val) && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{Object.entries(v.dietaryTags)
|
|
.filter(([, val]) => val)
|
|
.map(([tag]) => (
|
|
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{i < variations.length - 1 && <Separator />}
|
|
</div>
|
|
))}
|
|
|
|
<Button variant="outline" className="w-full" onClick={generate} disabled={generating}>
|
|
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
{tv("regenerate")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|