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.
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Search, Clock, ChefHat } from "lucide-react";
|
|
|
|
type RecipeResult = {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
difficulty: string | null;
|
|
prepMins: number | null;
|
|
cookMins: number | null;
|
|
authorName: string | null;
|
|
};
|
|
|
|
type SearchResponse = {
|
|
data: RecipeResult[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
};
|
|
|
|
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",
|
|
};
|
|
|
|
function RecipeCard({ recipe }: { recipe: RecipeResult }) {
|
|
const t = useTranslations("recipe");
|
|
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 (
|
|
<Link
|
|
href={`/recipes/${recipe.id}`}
|
|
className="group block rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3 className="font-semibold text-card-foreground group-hover:text-primary line-clamp-2">
|
|
{recipe.title}
|
|
</h3>
|
|
{recipe.difficulty && (
|
|
<Badge
|
|
variant="secondary"
|
|
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
|
|
>
|
|
{difficultyLabel}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{recipe.description && (
|
|
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{recipe.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>
|
|
);
|
|
}
|
|
|
|
export function SearchPageContent({ initialQuery }: { initialQuery: string }) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
const [query, setQuery] = useState(initialQuery);
|
|
const [inputValue, setInputValue] = useState(initialQuery);
|
|
const [difficulty, setDifficulty] = useState<string>("any");
|
|
const [maxMins, setMaxMins] = useState<string>("");
|
|
const [results, setResults] = useState<RecipeResult[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [offset, setOffset] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
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: SearchResponse = await res.json();
|
|
if (append) {
|
|
setResults((prev) => [...prev, ...json.data]);
|
|
} else {
|
|
setResults(json.data);
|
|
}
|
|
setTotal(json.total);
|
|
setOffset(off + json.data.length);
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Auto-focus input on mount
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
// Fetch on mount if initialQuery provided
|
|
useEffect(() => {
|
|
if (initialQuery) {
|
|
fetchResults(initialQuery, "any", "", 0);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Sync query param into URL
|
|
const updateUrl = useCallback(
|
|
(q: string) => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (q) params.set("q", q);
|
|
else params.delete("q");
|
|
router.replace(`/search?${params}`, { scroll: false });
|
|
},
|
|
[router, searchParams]
|
|
);
|
|
|
|
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 handleFilterChange = (newDiff: string, newMaxMins: string) => {
|
|
fetchResults(query, newDiff, newMaxMins, 0);
|
|
};
|
|
|
|
const handleDifficultyChange = (value: string | null) => {
|
|
const v = value ?? "any";
|
|
setDifficulty(v);
|
|
handleFilterChange(v, maxMins);
|
|
};
|
|
|
|
const handleMaxMinsChange = (value: string) => {
|
|
setMaxMins(value);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
handleFilterChange(difficulty, value);
|
|
}, 300);
|
|
};
|
|
|
|
const handleLoadMore = () => {
|
|
fetchResults(query, difficulty, maxMins, offset, true);
|
|
};
|
|
|
|
const hasMore = results.length < total;
|
|
const hasQuery = query.trim().length > 0;
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold mb-6">Search Recipes</h1>
|
|
|
|
{/* Search input */}
|
|
<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="Search for recipes..."
|
|
className="pl-10 h-12 text-base"
|
|
/>
|
|
</form>
|
|
|
|
{/* Filter bar */}
|
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
|
<Select value={difficulty} onValueChange={handleDifficultyChange}>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue placeholder="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>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
placeholder="Max minutes"
|
|
value={maxMins}
|
|
onChange={(e) => handleMaxMinsChange(e.target.value)}
|
|
className="w-36"
|
|
/>
|
|
</div>
|
|
|
|
{hasQuery && !loading && (
|
|
<span className="text-sm text-muted-foreground ml-auto">
|
|
{total} {total === 1 ? "recipe" : "recipes"} found
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* States */}
|
|
{!hasQuery && (
|
|
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
|
<Search className="h-12 w-12 mb-4 opacity-30" />
|
|
<p className="text-lg">Search for recipes...</p>
|
|
<p className="text-sm mt-1">Try "pasta", "vegan dessert", or "quick dinner"</p>
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{!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={handleLoadMore}
|
|
disabled={loadingMore}
|
|
className="min-w-32"
|
|
>
|
|
{loadingMore ? "Loading..." : "Load more"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|