fix(i18n): translate remaining recipe card/detail strings, localize AI responses
Recipe cards, the recipe detail page, and meal planner still rendered raw
"{n} servings"/"{n}m cook"/"{n} srv" text and untranslated difficulty labels
outside the earlier i18n pass. Also wires the user's locale into AI features
that previously always answered in English regardless of app language:
recipe chat, ingredient substitution, recipe ideas, and generate-from-idea.
The recipe-language picker in the AI generate dialog now defaults to the
user's locale instead of always defaulting to English.
This commit is contained in:
@@ -28,7 +28,7 @@ import { CommentsSection } from "@/components/social/comments-section";
|
|||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
|
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
|
||||||
import { getMessages } from "@/lib/i18n/server";
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -175,26 +175,26 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
{recipe.difficulty && (
|
{recipe.difficulty && (
|
||||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]}>{recipe.difficulty}</Badge>
|
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]}>{m.recipe.difficulty[recipe.difficulty]}</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
{recipe.baseServings} servings
|
{formatMessage(m.recipe.servings, { count: recipe.baseServings })}
|
||||||
</span>
|
</span>
|
||||||
{recipe.prepMins && (
|
{recipe.prepMins && (
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
{recipe.prepMins}m prep
|
{formatMessage(m.recipe.prep, { mins: recipe.prepMins })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{recipe.cookMins && (
|
{recipe.cookMins && (
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
<ChefHat className="h-4 w-4" />
|
<ChefHat className="h-4 w-4" />
|
||||||
{recipe.cookMins}m cook
|
{formatMessage(m.recipe.cook, { mins: recipe.cookMins })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{totalMins > 0 && recipe.prepMins && recipe.cookMins && (
|
{totalMins > 0 && recipe.prepMins && recipe.cookMins && (
|
||||||
<span className="text-muted-foreground">({totalMins}m total)</span>
|
<span className="text-muted-foreground">({formatMessage(m.recipe.total, { mins: totalMins })})</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1 text-muted-foreground ml-auto">
|
<span className="flex items-center gap-1 text-muted-foreground ml-auto">
|
||||||
<VisibilityIcon className="h-4 w-4" />
|
<VisibilityIcon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const Schema = z.object({
|
|||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
@@ -30,11 +32,13 @@ export async function POST(req: NextRequest) {
|
|||||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||||
|
|
||||||
const privateBio = await getUserPrivateBio(session!.user.id);
|
const privateBio = await getUserPrivateBio(session!.user.id);
|
||||||
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
|
||||||
const recipe = await generateRecipe(parsed.data.title, {
|
const recipe = await generateRecipe(parsed.data.title, {
|
||||||
provider: parsed.data.provider,
|
provider: parsed.data.provider,
|
||||||
model: parsed.data.model,
|
model: parsed.data.model,
|
||||||
userContext: privateBio ?? undefined,
|
userContext: privateBio ?? undefined,
|
||||||
|
language: LANG[locale] ?? "English",
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipeId = crypto.randomUUID();
|
const recipeId = crypto.randomUUID();
|
||||||
@@ -50,7 +54,7 @@ export async function POST(req: NextRequest) {
|
|||||||
difficulty: recipe.difficulty ?? null,
|
difficulty: recipe.difficulty ?? null,
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
aiGenerated: true,
|
aiGenerated: true,
|
||||||
language: "en",
|
language: locale,
|
||||||
dietaryTags: recipe.dietaryTags ?? {},
|
dietaryTags: recipe.dietaryTags ?? {},
|
||||||
tags: [],
|
tags: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const Schema = z.object({
|
|||||||
question: z.string().min(1).max(500),
|
question: z.string().min(1).max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
@@ -66,10 +68,12 @@ ${stepList || "None listed"}
|
|||||||
]);
|
]);
|
||||||
const model = resolveModel(config);
|
const model = resolveModel(config);
|
||||||
const bioContext = buildUserBioContext(privateBio);
|
const bioContext = buildUserBioContext(privateBio);
|
||||||
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
const lang = LANG[locale] ?? "English";
|
||||||
|
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model,
|
model,
|
||||||
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words.
|
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words. Respond in ${lang}.
|
||||||
|
|
||||||
${recipeContext}${bioContext}`,
|
${recipeContext}${bioContext}`,
|
||||||
prompt: parsed.data.question,
|
prompt: parsed.data.question,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const InputSchema = z.object({
|
|||||||
prompt: z.string().max(300).optional(),
|
prompt: z.string().max(300).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||||
|
|
||||||
const IdeasSchema = z.object({
|
const IdeasSchema = z.object({
|
||||||
ideas: z.array(z.object({
|
ideas: z.array(z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -45,6 +47,9 @@ export async function POST(req: NextRequest) {
|
|||||||
? `Consider the following user preferences when suggesting ideas:${bioContext}\n\n`
|
? `Consider the following user preferences when suggesting ideas:${bioContext}\n\n`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
const lang = LANG[locale] ?? "English";
|
||||||
|
|
||||||
const prompt = parsed.data.prompt?.trim()
|
const prompt = parsed.data.prompt?.trim()
|
||||||
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
|
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
|
||||||
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
|
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
|
||||||
@@ -52,6 +57,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const { object } = await generateObject({
|
const { object } = await generateObject({
|
||||||
model,
|
model,
|
||||||
schema: IdeasSchema,
|
schema: IdeasSchema,
|
||||||
|
system: `Respond in ${lang}.`,
|
||||||
prompt,
|
prompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,13 @@ export async function POST(req: NextRequest) {
|
|||||||
? { provider: parsed.data.provider, model: parsed.data.model }
|
? { provider: parsed.data.provider, model: parsed.data.model }
|
||||||
: await getDefaultProviderWithKey(session!.user.id);
|
: await getDefaultProviderWithKey(session!.user.id);
|
||||||
|
|
||||||
|
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||||
|
|
||||||
const substitutions = await substituteIngredient(
|
const substitutions = await substituteIngredient(
|
||||||
parsed.data.ingredient,
|
parsed.data.ingredient,
|
||||||
context,
|
context,
|
||||||
aiConfig
|
aiConfig,
|
||||||
|
locale
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ substitutions });
|
return NextResponse.json({ substitutions });
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export function MealPlanner({
|
|||||||
{entry?.recipe ? (
|
{entry?.recipe ? (
|
||||||
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
|
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
|
||||||
<div className="font-medium line-clamp-2 pr-4">{entry.recipe.title}</div>
|
<div className="font-medium line-clamp-2 pr-4">{entry.recipe.title}</div>
|
||||||
<div className="text-muted-foreground mt-0.5">{entry.servings} srv</div>
|
<div className="text-muted-foreground mt-0.5">{t("servingsAbbrev", { count: entry.servings })}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeEntry(entry)}
|
onClick={() => removeEntry(entry)}
|
||||||
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||||
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: "en", label: "English" },
|
{ code: "en", label: "English" },
|
||||||
@@ -72,11 +73,12 @@ export function AiGenerateDialog({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations("ai.generate");
|
const t = useTranslations("ai.generate");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
const [tab, setTab] = useState<Tab>("describe");
|
const [tab, setTab] = useState<Tab>("describe");
|
||||||
|
|
||||||
// describe tab
|
// describe tab
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [language, setLanguage] = useState("en");
|
const [language, setLanguage] = useState(locale);
|
||||||
const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
||||||
|
|
||||||
// photo tab
|
// photo tab
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Sparkles, Loader2 } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||||
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
|
|
||||||
type GeneratedRecipe = {
|
type GeneratedRecipe = {
|
||||||
ingredients: Array<{ rawName: string; quantity?: number; unit?: string; note?: string }>;
|
ingredients: Array<{ rawName: string; quantity?: number; unit?: string; note?: string }>;
|
||||||
@@ -24,6 +25,7 @@ export function GenerateContentButton({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations("ai.generate");
|
const t = useTranslations("ai.generate");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
@@ -36,7 +38,7 @@ export function GenerateContentButton({
|
|||||||
const genRes = await fetch("/api/v1/ai/generate", {
|
const genRes = await fetch("/api/v1/ai/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ prompt }),
|
body: JSON.stringify({ prompt, language: locale }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!genRes.ok) {
|
if (!genRes.ok) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { Clock, Users, Lock, Globe, Link2 } from "lucide-react";
|
import { Clock, Users, Lock, Globe, Link2 } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
@@ -30,6 +31,7 @@ const DIFFICULTY_COLOR = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||||
|
const t = useTranslations("recipe");
|
||||||
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
||||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
||||||
@@ -68,12 +70,12 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
|||||||
{totalMins > 0 && (
|
{totalMins > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{totalMins}m
|
{t("total", { mins: totalMins })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{recipe.difficulty && (
|
{recipe.difficulty && (
|
||||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4">
|
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4">
|
||||||
{recipe.difficulty}
|
{t(`difficulty.${recipe.difficulty}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function SelectableRecipeCard({
|
|||||||
selectMode: boolean;
|
selectMode: boolean;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("recipe");
|
||||||
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
||||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
||||||
@@ -139,12 +140,12 @@ function SelectableRecipeCard({
|
|||||||
{totalMins > 0 && (
|
{totalMins > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{totalMins}m
|
{t("total", { mins: totalMins })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{recipe.difficulty && (
|
{recipe.difficulty && (
|
||||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">
|
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">
|
||||||
{recipe.difficulty}
|
{t(`difficulty.${recipe.difficulty}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ type RecipeIdea = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResult }) {
|
function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResult }) {
|
||||||
|
const t = useTranslations("recipe");
|
||||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
const description = "description" in recipe ? recipe.description : null;
|
const description = "description" in recipe ? recipe.description : null;
|
||||||
|
const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard"
|
||||||
|
? t(`difficulty.${recipe.difficulty}`)
|
||||||
|
: recipe.difficulty;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/recipes/${recipe.id}`}
|
href={`/recipes/${recipe.id}`}
|
||||||
@@ -59,7 +63,7 @@ function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResu
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
||||||
>
|
>
|
||||||
{recipe.difficulty}
|
{difficultyLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +74,7 @@ function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResu
|
|||||||
{totalMins > 0 && (
|
{totalMins > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<Clock className="h-3.5 w-3.5" />
|
||||||
{totalMins}m
|
{t("total", { mins: totalMins })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{recipe.authorName && (
|
{recipe.authorName && (
|
||||||
@@ -103,6 +107,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations("explore");
|
const t = useTranslations("explore");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const tRecipe = useTranslations("recipe");
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useState(initialQuery);
|
const [inputValue, setInputValue] = useState(initialQuery);
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
@@ -388,7 +393,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[idea.difficulty] ?? ""}`}
|
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[idea.difficulty] ?? ""}`}
|
||||||
>
|
>
|
||||||
{idea.difficulty}
|
{tRecipe(`difficulty.${idea.difficulty}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{idea.description}</p>
|
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{idea.description}</p>
|
||||||
@@ -398,7 +403,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|||||||
))}
|
))}
|
||||||
{idea.totalMins && (
|
{idea.totalMins && (
|
||||||
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />{idea.totalMins}m
|
<Clock className="h-3 w-3" />{tRecipe("total", { mins: idea.totalMins })}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -39,7 +40,11 @@ const DIFFICULTY_COLORS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function RecipeCard({ recipe }: { recipe: RecipeResult }) {
|
function RecipeCard({ recipe }: { recipe: RecipeResult }) {
|
||||||
|
const t = useTranslations("recipe");
|
||||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
|
const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard"
|
||||||
|
? t(`difficulty.${recipe.difficulty}`)
|
||||||
|
: recipe.difficulty;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/recipes/${recipe.id}`}
|
href={`/recipes/${recipe.id}`}
|
||||||
@@ -54,7 +59,7 @@ function RecipeCard({ recipe }: { recipe: RecipeResult }) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
||||||
>
|
>
|
||||||
{recipe.difficulty}
|
{difficultyLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +70,7 @@ function RecipeCard({ recipe }: { recipe: RecipeResult }) {
|
|||||||
{totalMins > 0 && (
|
{totalMins > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<Clock className="h-3.5 w-3.5" />
|
||||||
{totalMins}m
|
{t("total", { mins: totalMins })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{recipe.authorName && (
|
{recipe.authorName && (
|
||||||
|
|||||||
@@ -13,18 +13,22 @@ const SubstitutionsSchema = z.object({
|
|||||||
|
|
||||||
export type Substitution = z.infer<typeof SubstitutionsSchema>["substitutions"][number];
|
export type Substitution = z.infer<typeof SubstitutionsSchema>["substitutions"][number];
|
||||||
|
|
||||||
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
||||||
|
|
||||||
export async function substituteIngredient(
|
export async function substituteIngredient(
|
||||||
ingredient: string,
|
ingredient: string,
|
||||||
recipeContext: string,
|
recipeContext: string,
|
||||||
config?: AiConfig
|
config?: AiConfig,
|
||||||
|
locale?: string
|
||||||
): Promise<Substitution[]> {
|
): Promise<Substitution[]> {
|
||||||
const model = resolveModel(config);
|
const model = resolveModel(config);
|
||||||
|
const lang = LANG[locale ?? "en"] ?? "English";
|
||||||
|
|
||||||
const { object } = await generateObject({
|
const { object } = await generateObject({
|
||||||
model,
|
model,
|
||||||
schema: SubstitutionsSchema,
|
schema: SubstitutionsSchema,
|
||||||
system:
|
system:
|
||||||
"You are a professional chef specializing in ingredient substitutions. Provide practical, commonly available substitutes. For each substitute: name what to use, the ratio (e.g. '1:1', '3/4 the amount'), and a brief note on technique adjustments. Rate the flavor impact honestly.",
|
`You are a professional chef specializing in ingredient substitutions. Provide practical, commonly available substitutes. For each substitute: name what to use, the ratio (e.g. '1:1', '3/4 the amount'), and a brief note on technique adjustments. Rate the flavor impact honestly. Respond in ${lang}.`,
|
||||||
prompt: `Suggest 3-4 substitutes for "${ingredient}" in this recipe context: ${recipeContext}`,
|
prompt: `Suggest 3-4 substitutes for "${ingredient}" in this recipe context: ${recipeContext}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,7 @@
|
|||||||
"addTo": "Add to {day} – {meal}",
|
"addTo": "Add to {day} – {meal}",
|
||||||
"searchRecipes": "Search recipes…",
|
"searchRecipes": "Search recipes…",
|
||||||
"servings": "Servings",
|
"servings": "Servings",
|
||||||
|
"servingsAbbrev": "{count} srv",
|
||||||
"difficulty": "Difficulty",
|
"difficulty": "Difficulty",
|
||||||
"noRecipes": "No recipes found",
|
"noRecipes": "No recipes found",
|
||||||
"addFailed": "Failed to add",
|
"addFailed": "Failed to add",
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
"addTo": "Ajouter à {day} – {meal}",
|
"addTo": "Ajouter à {day} – {meal}",
|
||||||
"searchRecipes": "Rechercher des recettes…",
|
"searchRecipes": "Rechercher des recettes…",
|
||||||
"servings": "Portions",
|
"servings": "Portions",
|
||||||
|
"servingsAbbrev": "{count} pers.",
|
||||||
"difficulty": "Difficulté",
|
"difficulty": "Difficulté",
|
||||||
"noRecipes": "Aucune recette trouvée",
|
"noRecipes": "Aucune recette trouvée",
|
||||||
"addFailed": "Échec de l'ajout",
|
"addFailed": "Échec de l'ajout",
|
||||||
|
|||||||
Reference in New Issue
Block a user