b1f745da66
- Drag-reorder used verticalListSortingStrategy on a multi-column grid, which computes wrong transforms for grid reflow — swapped to rectSortingStrategy so cards actually animate live while dragging. - Grip handle was rendered as a sibling of (not a descendant of) the `group` element its `group-hover:opacity-100` depended on, so it was permanently invisible. Fixed the DOM nesting and made it always partially visible instead of hover-only. - common.edit was missing from both locales (not just French) — edit-collection-dialog.tsx was the first caller to hit it. - Root cause of "generated in my language but Translate still shows": generate-meal, meal-plan/generate, and adapt never set recipes.language on the row they inserted, so the button's `!recipe.language || ...` check always fell back to "show it". Fixed at all three insert sites. - Translate dialog was entirely hardcoded English (title, description, language names, buttons) despite i18n keys already existing for most of it — now uses them, plus new translated language-name keys. - Recipe tags now render on the recipe detail page (previously grid-card only). - Collection header actions converted to icon-only + tooltip, matching the recipe page's pattern instead of icon+label buttons. - Collections list search now also matches recipe titles inside each collection, not just the collection's own name/description. - Explore page links to /collections/explore next to its tabs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
156 lines
5.4 KiB
TypeScript
156 lines
5.4 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { and, eq } from "@epicure/db";
|
|
import { db, recipes, recipeIngredients, recipeSteps, recipeVariations } from "@epicure/db";
|
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
|
|
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
|
|
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
|
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
|
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
|
import { isRecipeUnchanged } from "@/lib/recipe-diff";
|
|
|
|
const Schema = z.object({
|
|
excludeIngredients: z.array(z.string()).default([]),
|
|
extraConstraints: z.string().max(500).optional(),
|
|
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
|
model: z.string().optional(),
|
|
});
|
|
|
|
type Params = { params: Promise<{ id: string }> };
|
|
|
|
export async function POST(req: NextRequest, { params }: Params) {
|
|
const { session, response } = await requireSessionOrApiKey(req);
|
|
if (response) return response;
|
|
|
|
const { id } = await params;
|
|
const userId = session!.user.id;
|
|
|
|
const recipe = await db.query.recipes.findFirst({
|
|
where: and(eq(recipes.id, id)),
|
|
with: {
|
|
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
|
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
|
},
|
|
});
|
|
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== userId)) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await req.json() as unknown;
|
|
const parsed = Schema.safeParse(body);
|
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
|
|
|
if (parsed.data.excludeIngredients.length === 0 && !parsed.data.extraConstraints?.trim()) {
|
|
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
|
|
}
|
|
|
|
const [configResult, privateBio] = await Promise.all([
|
|
resolveAiConfigOrError(() => withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model })),
|
|
getUserPrivateBio(userId),
|
|
]);
|
|
if (!configResult.ok) return configResult.response;
|
|
const aiConfig = configResult.data;
|
|
|
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
|
|
|
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () =>
|
|
adaptRecipe(
|
|
{
|
|
title: recipe.title,
|
|
description: recipe.description,
|
|
baseServings: recipe.baseServings,
|
|
ingredients: recipe.ingredients,
|
|
steps: recipe.steps,
|
|
},
|
|
{
|
|
excludeIngredients: parsed.data.excludeIngredients,
|
|
extraConstraints: parsed.data.extraConstraints,
|
|
},
|
|
{ ...aiConfig, userContext: privateBio ?? undefined },
|
|
locale
|
|
), { skipQuota: aiConfig.isByok }
|
|
);
|
|
if (!result.ok) return result.response;
|
|
const adapted = result.data;
|
|
|
|
// The AI sometimes "adapts" a recipe into something byte-for-byte
|
|
// identical (e.g. it decides the constraints are already satisfied) —
|
|
// don't persist a duplicate recipe/variation when nothing actually changed.
|
|
if (isRecipeUnchanged(recipe, adapted)) {
|
|
return NextResponse.json({ id: recipe.id, adaptationNotes: adapted.adaptationNotes, unchanged: true });
|
|
}
|
|
|
|
try {
|
|
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro" | "family", "recipe");
|
|
} catch (err) {
|
|
if (err instanceof TierLimitError) {
|
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const newId = crypto.randomUUID();
|
|
const now = new Date();
|
|
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(recipes).values({
|
|
id: newId,
|
|
authorId: userId,
|
|
title: adapted.title,
|
|
description: adapted.description,
|
|
baseServings: adapted.baseServings,
|
|
visibility: "private",
|
|
language: locale,
|
|
difficulty: adapted.difficulty,
|
|
prepMins: adapted.prepMins,
|
|
cookMins: adapted.cookMins,
|
|
dietaryTags: adapted.dietaryTags ?? {},
|
|
aiGenerated: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
if (adapted.ingredients.length > 0) {
|
|
await tx.insert(recipeIngredients).values(
|
|
adapted.ingredients.map((ing, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: newId,
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
|
|
unit: ing.unit,
|
|
note: ing.note,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (adapted.steps.length > 0) {
|
|
await tx.insert(recipeSteps).values(
|
|
adapted.steps.map((step, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: newId,
|
|
instruction: step.instruction,
|
|
timerSeconds: step.timerSeconds,
|
|
order: i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
await tx.insert(recipeVariations).values({
|
|
id: crypto.randomUUID(),
|
|
parentRecipeId: recipe.id,
|
|
childRecipeId: newId,
|
|
description: adapted.adaptationNotes,
|
|
aiGenerated: true,
|
|
createdAt: now,
|
|
});
|
|
});
|
|
} catch {
|
|
return NextResponse.json({ error: "Failed to save the adapted recipe. Please try again." }, { status: 500 });
|
|
}
|
|
|
|
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
|
|
}
|