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.
470 lines
17 KiB
TypeScript
470 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight } from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page";
|
|
|
|
const DIFFICULTY_COLORS: Record<string, string> = {
|
|
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
|
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
|
};
|
|
|
|
type SearchRecipeResult = {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
difficulty: string | null;
|
|
prepMins: number | null;
|
|
cookMins: number | null;
|
|
authorName: string | null;
|
|
};
|
|
|
|
type SearchResponse = {
|
|
data: SearchRecipeResult[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
};
|
|
|
|
type RecipeIdea = {
|
|
title: string;
|
|
description: string;
|
|
tags: string[];
|
|
difficulty: "easy" | "medium" | "hard";
|
|
totalMins?: number;
|
|
};
|
|
|
|
function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResult }) {
|
|
const t = useTranslations("recipe");
|
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
|
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 (
|
|
<Link
|
|
href={`/recipes/${recipe.id}`}
|
|
className="group block rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md min-w-[200px]"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3 className="font-semibold text-card-foreground group-hover:text-primary line-clamp-2 flex-1">
|
|
{recipe.title}
|
|
</h3>
|
|
{recipe.difficulty && (
|
|
<Badge
|
|
variant="secondary"
|
|
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
|
>
|
|
{difficultyLabel}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{description && (
|
|
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{description}</p>
|
|
)}
|
|
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
|
|
{totalMins > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
{t("total", { mins: totalMins })}
|
|
</span>
|
|
)}
|
|
{recipe.authorName && (
|
|
<span className="flex items-center gap-1">
|
|
<ChefHat className="h-3.5 w-3.5" />
|
|
{recipe.authorName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function HorizontalScroll({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex gap-4 overflow-x-auto pb-2 -mx-4 px-4 snap-x snap-mandatory">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type Props = {
|
|
trending: ExploreRecipeResult[];
|
|
recent: ExploreRecipeResult[];
|
|
initialQuery: string;
|
|
};
|
|
|
|
export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const t = useTranslations("explore");
|
|
const tCommon = useTranslations("common");
|
|
const tRecipe = useTranslations("recipe");
|
|
|
|
const [inputValue, setInputValue] = useState(initialQuery);
|
|
const [query, setQuery] = useState(initialQuery);
|
|
const [difficulty, setDifficulty] = useState("any");
|
|
const [maxMins, setMaxMins] = useState("");
|
|
const [results, setResults] = useState<SearchRecipeResult[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [offset, setOffset] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
const [ideasPrompt, setIdeasPrompt] = useState("");
|
|
const [ideas, setIdeas] = useState<RecipeIdea[]>([]);
|
|
const [ideasLoading, setIdeasLoading] = useState(false);
|
|
const [generatingId, setGeneratingId] = useState<string | null>(null);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const fetchResults = useCallback(
|
|
async (q: string, diff: string, maxM: string, off: number, append = false) => {
|
|
if (!q.trim()) {
|
|
setResults([]);
|
|
setTotal(0);
|
|
setOffset(0);
|
|
return;
|
|
}
|
|
if (off === 0) setLoading(true);
|
|
else setLoadingMore(true);
|
|
try {
|
|
const params = new URLSearchParams({ q, limit: "20", offset: String(off) });
|
|
if (diff && diff !== "any") params.set("difficulty", diff);
|
|
if (maxM) params.set("maxMins", maxM);
|
|
const res = await fetch(`/api/v1/search?${params}`);
|
|
if (!res.ok) return;
|
|
const json = await res.json() as SearchResponse;
|
|
if (append) setResults((prev) => [...prev, ...json.data]);
|
|
else setResults(json.data);
|
|
setTotal(json.total);
|
|
setOffset(off + json.data.length);
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (initialQuery) fetchResults(initialQuery, "any", "", 0);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const updateUrl = useCallback(
|
|
(q: string) => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (q) params.set("q", q);
|
|
else params.delete("q");
|
|
router.replace(`/explore?${params}`, { scroll: false });
|
|
},
|
|
[router, searchParams]
|
|
);
|
|
|
|
const fetchIdeas = useCallback(async (prompt: string) => {
|
|
setIdeasLoading(true);
|
|
setIdeas([]);
|
|
try {
|
|
const res = await fetch("/api/v1/ai/recipe-ideas", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ prompt }),
|
|
});
|
|
if (!res.ok) return;
|
|
const data = await res.json() as RecipeIdea[];
|
|
setIdeas(data);
|
|
} finally {
|
|
setIdeasLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const generateFromIdea = useCallback(async (idea: RecipeIdea) => {
|
|
const key = idea.title;
|
|
setGeneratingId(key);
|
|
try {
|
|
const res = await fetch("/api/v1/ai/generate-from-idea", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: idea.title }),
|
|
});
|
|
if (!res.ok) return;
|
|
const { id } = await res.json() as { id: string };
|
|
router.push(`/recipes/${id}`);
|
|
} finally {
|
|
setGeneratingId(null);
|
|
}
|
|
}, [router]);
|
|
|
|
const handleInputChange = (value: string) => {
|
|
setInputValue(value);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
setQuery(value);
|
|
updateUrl(value);
|
|
fetchResults(value, difficulty, maxMins, 0);
|
|
}, 300);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
setQuery(inputValue);
|
|
updateUrl(inputValue);
|
|
fetchResults(inputValue, difficulty, maxMins, 0);
|
|
};
|
|
|
|
const handleDifficultyChange = (value: string | null) => {
|
|
const v = value ?? "any";
|
|
setDifficulty(v);
|
|
fetchResults(query, v, maxMins, 0);
|
|
};
|
|
|
|
const handleMaxMinsChange = (value: string) => {
|
|
setMaxMins(value);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
fetchResults(query, difficulty, value, 0);
|
|
}, 300);
|
|
};
|
|
|
|
const hasQuery = query.trim().length > 0;
|
|
const hasMore = results.length < total;
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto space-y-10">
|
|
{/* Search bar */}
|
|
<div className="space-y-3">
|
|
<h1 className="text-3xl font-bold">Explore</h1>
|
|
<form onSubmit={handleSubmit} className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
ref={inputRef}
|
|
value={inputValue}
|
|
onChange={(e) => handleInputChange(e.target.value)}
|
|
placeholder={t("searchPlaceholder")}
|
|
className="pl-10 h-12 text-base"
|
|
autoFocus={!!initialQuery}
|
|
/>
|
|
</form>
|
|
|
|
{hasQuery && (
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Select value={difficulty} onValueChange={handleDifficultyChange}>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue placeholder={tCommon("difficulty")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="any">Any difficulty</SelectItem>
|
|
<SelectItem value="easy">Easy</SelectItem>
|
|
<SelectItem value="medium">Medium</SelectItem>
|
|
<SelectItem value="hard">Hard</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
placeholder={t("maxMinutes")}
|
|
value={maxMins}
|
|
onChange={(e) => handleMaxMinsChange(e.target.value)}
|
|
className="w-36"
|
|
/>
|
|
{!loading && (
|
|
<span className="text-sm text-muted-foreground ml-auto">
|
|
{total} {total === 1 ? "recipe" : "recipes"} found
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search results */}
|
|
{hasQuery && loading && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div key={i} className="rounded-lg border bg-card p-4 space-y-3">
|
|
<div className="h-5 bg-muted rounded animate-pulse" />
|
|
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
|
|
<div className="h-3 bg-muted rounded animate-pulse w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{hasQuery && !loading && results.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
|
<ChefHat className="h-12 w-12 mb-4 opacity-30" />
|
|
<p className="text-lg font-medium">No recipes found for “{query}”</p>
|
|
<p className="text-sm mt-1">Try different keywords or remove filters</p>
|
|
</div>
|
|
)}
|
|
|
|
{hasQuery && !loading && results.length > 0 && (
|
|
<>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{results.map((recipe) => (
|
|
<RecipeCard key={recipe.id} recipe={recipe} />
|
|
))}
|
|
</div>
|
|
{hasMore && (
|
|
<div className="flex justify-center pt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => fetchResults(query, difficulty, maxMins, offset, true)}
|
|
disabled={loadingMore}
|
|
className="min-w-32"
|
|
>
|
|
{loadingMore ? "Loading…" : "Load more"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Explore sections — shown when no query */}
|
|
{!hasQuery && (
|
|
<>
|
|
{/* AI Recipe Ideas */}
|
|
<section className="rounded-xl border bg-gradient-to-br from-violet-50 to-indigo-50 dark:from-violet-950/30 dark:to-indigo-950/30 p-6 space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-violet-500" />
|
|
<h2 className="text-xl font-semibold">Recipe ideas</h2>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">Tell the AI what you're in the mood for, or let it surprise you.</p>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
fetchIdeas(ideasPrompt);
|
|
}}
|
|
className="flex gap-2"
|
|
>
|
|
<Input
|
|
value={ideasPrompt}
|
|
onChange={(e) => setIdeasPrompt(e.target.value)}
|
|
placeholder={t("aiSearchPlaceholder")}
|
|
className="bg-background"
|
|
/>
|
|
<Button type="submit" disabled={ideasLoading} variant="secondary" className="shrink-0">
|
|
{ideasLoading ? (
|
|
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4 animate-pulse" /> Generating…</span>
|
|
) : (
|
|
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4" /> Get ideas</span>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
disabled={ideasLoading}
|
|
onClick={() => { setIdeasPrompt(""); fetchIdeas(""); }}
|
|
className="shrink-0"
|
|
>
|
|
Surprise me
|
|
</Button>
|
|
</form>
|
|
|
|
{ideasLoading && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="rounded-lg border bg-background p-4 space-y-2">
|
|
<div className="h-4 bg-muted rounded animate-pulse" />
|
|
<div className="h-3 bg-muted rounded animate-pulse w-5/6" />
|
|
<div className="h-3 bg-muted rounded animate-pulse w-2/3" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!ideasLoading && ideas.length > 0 && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
|
{ideas.map((idea) => (
|
|
<div key={idea.title} className="rounded-lg border bg-background p-4 space-y-2 flex flex-col">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3 className="font-medium text-sm leading-snug flex-1">{idea.title}</h3>
|
|
<Badge
|
|
variant="secondary"
|
|
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[idea.difficulty] ?? ""}`}
|
|
>
|
|
{tRecipe(`difficulty.${idea.difficulty}`)}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{idea.description}</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{idea.tags.map((tag) => (
|
|
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
|
))}
|
|
{idea.totalMins && (
|
|
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />{tRecipe("total", { mins: idea.totalMins })}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
className="w-full mt-auto"
|
|
disabled={generatingId !== null}
|
|
onClick={() => generateFromIdea(idea)}
|
|
>
|
|
{generatingId === idea.title ? (
|
|
<span className="flex items-center gap-2"><Wand2 className="h-3.5 w-3.5 animate-pulse" /> Generating…</span>
|
|
) : (
|
|
<span className="flex items-center gap-2"><ArrowRight className="h-3.5 w-3.5" /> Generate full recipe</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Flame className="h-5 w-5 text-orange-500" />
|
|
<h2 className="text-xl font-semibold">Trending this week</h2>
|
|
</div>
|
|
{trending.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm py-8 text-center">
|
|
No trending recipes yet. Be the first to share one!
|
|
</p>
|
|
) : (
|
|
<HorizontalScroll>
|
|
{trending.map((recipe) => (
|
|
<div key={recipe.id} className="snap-start shrink-0 w-56">
|
|
<RecipeCard recipe={recipe} />
|
|
</div>
|
|
))}
|
|
</HorizontalScroll>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Clock className="h-5 w-5 text-blue-500" />
|
|
<h2 className="text-xl font-semibold">Recently added</h2>
|
|
</div>
|
|
{recent.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm py-8 text-center">
|
|
No public recipes yet.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{recent.map((recipe) => (
|
|
<RecipeCard key={recipe.id} recipe={recipe} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|