2154512e54
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.
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useRouter } from "next/navigation";
|
|
import { Sparkles, Loader2, Camera, Type, Upload, X, Shuffle } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
|
import { useLocale } from "@/lib/i18n/provider";
|
|
|
|
const LANGUAGES = [
|
|
{ code: "en", label: "English" },
|
|
{ code: "fr", label: "French" },
|
|
{ code: "es", label: "Spanish" },
|
|
{ code: "de", label: "German" },
|
|
{ code: "it", label: "Italian" },
|
|
{ code: "pt", label: "Portuguese" },
|
|
{ code: "ja", label: "Japanese" },
|
|
{ code: "zh", label: "Chinese" },
|
|
{ code: "ar", label: "Arabic" },
|
|
];
|
|
|
|
const SURPRISE_PROMPTS = [
|
|
"A cozy one-pot dish with whatever is in my pantry on a rainy evening",
|
|
"A vibrant street food recipe from Southeast Asia",
|
|
"A classic French bistro dish reimagined as a weeknight dinner",
|
|
"A hearty plant-based bowl with bold spices",
|
|
"A 5-ingredient pasta with pantry staples",
|
|
"An unexpected fusion of Japanese and Mexican flavors",
|
|
"A light summer salad with fresh herbs and a citrus dressing",
|
|
"A slow-cooked braise that fills the house with aroma",
|
|
"A festive appetizer that looks impressive but is easy to make",
|
|
"A warming spiced soup from the Middle East",
|
|
"A crowd-pleasing comfort food with a twist",
|
|
"A 20-minute weeknight dinner using chicken thighs",
|
|
"A rich chocolate dessert with a molten center",
|
|
"A crispy baked dish that tastes deep-fried",
|
|
"A refreshing no-cook meal for hot summer days",
|
|
];
|
|
|
|
type Tab = "describe" | "photo";
|
|
|
|
type GeneratedRecipe = {
|
|
title: string;
|
|
description?: string;
|
|
baseServings?: number;
|
|
prepMins?: number;
|
|
cookMins?: number;
|
|
difficulty?: "easy" | "medium" | "hard";
|
|
dietaryTags?: Record<string, boolean>;
|
|
ingredients: Array<{ rawName: string; quantity?: string; unit?: string; note?: string }>;
|
|
steps: Array<{ instruction: string; timerSeconds?: number }>;
|
|
};
|
|
|
|
export function AiGenerateDialog({
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const t = useTranslations("ai.generate");
|
|
const router = useRouter();
|
|
const locale = useLocale();
|
|
const [tab, setTab] = useState<Tab>("describe");
|
|
|
|
// describe tab
|
|
const [prompt, setPrompt] = useState("");
|
|
const [language, setLanguage] = useState(locale);
|
|
const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
|
|
|
// photo tab
|
|
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
|
const [photoBase64, setPhotoBase64] = useState<string | null>(null);
|
|
const [photoMime, setPhotoMime] = useState<string>("image/jpeg");
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
function handleClose() {
|
|
if (busy) return;
|
|
onOpenChange(false);
|
|
setTimeout(() => {
|
|
setPrompt("");
|
|
setPhotoPreview(null);
|
|
setPhotoBase64(null);
|
|
setTab("describe");
|
|
}, 200);
|
|
}
|
|
|
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setPhotoMime(file.type);
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
const dataUrl = ev.target?.result as string;
|
|
setPhotoPreview(dataUrl);
|
|
// strip the data:mime;base64, prefix
|
|
setPhotoBase64(dataUrl.split(",")[1] ?? null);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
e.target.value = "";
|
|
}
|
|
|
|
async function handleDescribeGenerate() {
|
|
if (!prompt.trim()) return;
|
|
setBusy(true);
|
|
try {
|
|
const res = await fetch("/api/v1/ai/generate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ prompt: prompt.trim(), language, difficulty: difficulty || undefined }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json() as { error?: string };
|
|
toast.error(err.error ?? t("error"));
|
|
return;
|
|
}
|
|
const generated = await res.json() as GeneratedRecipe;
|
|
const saveRes = await fetch("/api/v1/recipes", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true, language }),
|
|
});
|
|
if (!saveRes.ok) { toast.error(t("saveError")); return; }
|
|
const saved = await saveRes.json() as { id: string };
|
|
toast.success(t("success"));
|
|
handleClose();
|
|
router.push(`/recipes/${saved.id}/edit`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handlePhotoGenerate() {
|
|
if (!photoBase64) return;
|
|
setBusy(true);
|
|
try {
|
|
const res = await fetch("/api/v1/ai/import-photo", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ imageBase64: photoBase64, mimeType: photoMime }),
|
|
});
|
|
if (!res.ok) {
|
|
let msg = t("photoError");
|
|
try { msg = ((await res.json()) as { error?: string }).error ?? msg; } catch { /* non-JSON body */ }
|
|
toast.error(msg);
|
|
return;
|
|
}
|
|
const { id } = await res.json() as { id: string };
|
|
toast.success(t("photoSuccess"));
|
|
handleClose();
|
|
router.push(`/recipes/${id}/edit`);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const canSubmit = tab === "describe" ? !!prompt.trim() : !!photoBase64;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-primary" />
|
|
Generate recipe with AI
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Describe a dish in words or snap a photo — AI builds the full recipe.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Tab switcher */}
|
|
<div className="flex gap-1 p-1 bg-muted rounded-lg">
|
|
{([
|
|
{ key: "describe", label: "Describe", icon: Type },
|
|
{ key: "photo", label: "From photo", icon: Camera },
|
|
] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => setTab(key)}
|
|
disabled={busy}
|
|
className={cn(
|
|
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-all",
|
|
tab === key
|
|
? "bg-background shadow-sm text-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Describe tab */}
|
|
{tab === "describe" && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="ai-prompt">What do you want to cook?</Label>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const idx = Math.floor(Math.random() * SURPRISE_PROMPTS.length);
|
|
setPrompt(SURPRISE_PROMPTS[idx]!);
|
|
}}
|
|
disabled={busy}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors disabled:opacity-50"
|
|
>
|
|
<Shuffle className="h-3 w-3" />
|
|
Surprise me
|
|
</button>
|
|
</div>
|
|
<Textarea
|
|
id="ai-prompt"
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
placeholder={t("placeholder")}
|
|
rows={4}
|
|
disabled={busy}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Recipe language</Label>
|
|
<Select value={language} onValueChange={(v) => v && setLanguage(v)} disabled={busy}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{LANGUAGES.map((l) => (
|
|
<SelectItem key={l.code} value={l.code}>{l.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Difficulty</Label>
|
|
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("anyDifficulty")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">Any</SelectItem>
|
|
<SelectItem value="easy">Easy</SelectItem>
|
|
<SelectItem value="medium">Medium</SelectItem>
|
|
<SelectItem value="hard">Hard</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Photo tab */}
|
|
{tab === "photo" && (
|
|
<div className="space-y-3">
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
className="hidden"
|
|
onChange={handleFileChange}
|
|
/>
|
|
{photoPreview ? (
|
|
<div className="relative rounded-xl overflow-hidden border bg-muted">
|
|
<img
|
|
src={photoPreview}
|
|
alt="Dish preview"
|
|
className="w-full max-h-64 object-cover"
|
|
/>
|
|
<button
|
|
onClick={() => { setPhotoPreview(null); setPhotoBase64(null); }}
|
|
className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/60 flex items-center justify-center text-white hover:bg-black/80 transition-colors"
|
|
disabled={busy}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => fileRef.current?.click()}
|
|
disabled={busy}
|
|
className="w-full border-2 border-dashed rounded-xl p-10 flex flex-col items-center gap-3 text-muted-foreground hover:border-primary/50 hover:text-foreground transition-colors"
|
|
>
|
|
<Upload className="h-8 w-8" />
|
|
<div className="text-sm text-center">
|
|
<span className="font-medium text-foreground">Click to upload</span> or drag a food photo
|
|
<p className="text-xs mt-1">JPEG, PNG or WebP · AI will reverse-engineer the recipe</p>
|
|
</div>
|
|
</button>
|
|
)}
|
|
{photoPreview && (
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
AI will analyze this dish and infer ingredients, quantities, and cooking steps.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<FakeProgressBar
|
|
active={busy}
|
|
durationMs={tab === "photo" ? 12000 : 10000}
|
|
label={busy ? (tab === "photo" ? "Analyzing dish photo…" : "Generating recipe…") : undefined}
|
|
/>
|
|
{/* Actions */}
|
|
<div className="flex gap-2 justify-end pt-1">
|
|
<Button variant="ghost" onClick={handleClose} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={tab === "describe" ? () => { void handleDescribeGenerate(); } : () => { void handlePhotoGenerate(); }}
|
|
disabled={!canSubmit || busy}
|
|
>
|
|
{busy ? (
|
|
<><Loader2 className="h-4 w-4 animate-spin" />Analyzing…</>
|
|
) : (
|
|
<><Sparkles className="h-4 w-4" />{tab === "photo" ? "Recognize dish" : "Generate"}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|