feat: auto-classify AI recipes as dish/drink (v0.33.0)

generate, generate-from-idea, URL import, and photo import all now
have the AI set recipeType directly (defaulting to "dish" whenever
ambiguous, per instruction), with cookMins force-cleared for drinks
as a backstop in case the model doesn't follow the prompt guidance.
1-serving-by-default for unspecified drink quantities is left to the
model's own judgment of the request text, since there's no reliable
way to detect "was a serving count stated" without re-parsing intent.

Drink recipes get a distinct default icon (no cover photo case), and
Explore gains the same dish/drink Type filter My Recipes already had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-14 15:17:33 +02:00
parent 3042d289a0
commit af92b8cc71
16 changed files with 80 additions and 17 deletions
+2 -1
View File
@@ -20,6 +20,7 @@ type Recipe = {
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
sourceUrl?: string | null;
recipeType?: "dish" | "drink";
};
const VISIBILITY_ICON = {
@@ -56,7 +57,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
</div>
) : (
<div className="aspect-video bg-gradient-to-br from-muted to-muted/50 flex items-center justify-center text-4xl">
🍽
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
</div>
)}
<CardHeader className="pb-2">
+2 -1
View File
@@ -49,6 +49,7 @@ type Recipe = {
isBatchCook?: boolean;
dishCount?: number;
sourceUrl?: string | null;
recipeType?: "dish" | "drink";
};
/** Stops the click from bubbling to the card's own Link/select handler. */
@@ -92,7 +93,7 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
/>
) : (
<div className={cn("w-full h-full flex items-center justify-center text-2xl", selectMode && !selected && "opacity-60")}>
🍽
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
</div>
)}
{selectMode && selected && <div className="absolute inset-0 bg-primary/20" />}
@@ -95,6 +95,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
const [inputValue, setInputValue] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [difficulty, setDifficulty] = useState("any");
const [recipeType, setRecipeType] = useState("any");
const [maxMins, setMaxMins] = useState("");
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
const [results, setResults] = useState<SearchRecipeResult[]>([]);
@@ -115,7 +116,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchResults = useCallback(
async (q: string, diff: string, maxM: string, off: number, tags: string[], append = false) => {
async (q: string, diff: string, maxM: string, off: number, tags: string[], type = "any", append = false) => {
if (!q.trim()) {
setResults([]);
setTotal(0);
@@ -127,6 +128,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
try {
const params = new URLSearchParams({ q, limit: "20", offset: String(off) });
if (diff && diff !== "any") params.set("difficulty", diff);
if (type && type !== "any") params.set("recipeType", type);
if (maxM) params.set("maxMins", maxM);
if (tags.length > 0) params.set("tags", tags.join(","));
const res = await fetch(`/api/v1/search?${params}`);
@@ -162,7 +164,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
useEffect(() => {
if (initialQuery) {
fetchResults(initialQuery, "any", "", 0, []);
fetchResults(initialQuery, "any", "", 0, [], "any");
fetchPeople(initialQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -218,7 +220,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
debounceRef.current = setTimeout(() => {
setQuery(value);
updateUrl(value);
fetchResults(value, difficulty, maxMins, 0, [...selectedTags]);
fetchResults(value, difficulty, maxMins, 0, [...selectedTags], recipeType);
fetchPeople(value);
}, 300);
};
@@ -228,21 +230,27 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
if (debounceRef.current) clearTimeout(debounceRef.current);
setQuery(inputValue);
updateUrl(inputValue);
fetchResults(inputValue, difficulty, maxMins, 0, [...selectedTags]);
fetchResults(inputValue, difficulty, maxMins, 0, [...selectedTags], recipeType);
fetchPeople(inputValue);
};
const handleDifficultyChange = (value: string | null) => {
const v = value ?? "any";
setDifficulty(v);
fetchResults(query, v, maxMins, 0, [...selectedTags]);
fetchResults(query, v, maxMins, 0, [...selectedTags], recipeType);
};
const handleRecipeTypeChange = (value: string | null) => {
const v = value ?? "any";
setRecipeType(v);
fetchResults(query, difficulty, maxMins, 0, [...selectedTags], v);
};
const handleMaxMinsChange = (value: string) => {
setMaxMins(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchResults(query, difficulty, value, 0, [...selectedTags]);
fetchResults(query, difficulty, value, 0, [...selectedTags], recipeType);
}, 300);
};
@@ -251,7 +259,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
const next = new Set(prev);
if (next.has(tag)) next.delete(tag);
else next.add(tag);
fetchResults(query, difficulty, maxMins, 0, [...next]);
fetchResults(query, difficulty, maxMins, 0, [...next], recipeType);
return next;
});
};
@@ -294,6 +302,17 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
<Select value={recipeType} onValueChange={handleRecipeTypeChange}>
<SelectTrigger className="w-40">
<SelectValue placeholder={t("anyType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">{t("anyType")}</SelectItem>
<SelectItem value="dish">{tRecipe("recipeType.dish")}</SelectItem>
<SelectItem value="drink">{tRecipe("recipeType.drink")}</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={1}
@@ -392,7 +411,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={() => fetchResults(query, difficulty, maxMins, offset, [...selectedTags], true)}
onClick={() => fetchResults(query, difficulty, maxMins, offset, [...selectedTags], recipeType, true)}
disabled={loadingMore}
className="min-w-32"
>