diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54efd8d..e552fc6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
+## 0.33.0 — 2026-07-14 17:35
+
+### Added
+- **AI recipe generation/import now auto-detects drinks vs dishes** — generate, generate-from-idea, URL import, and photo import all classify the result and skip cook time for drinks, defaulting to dish whenever it's ambiguous.
+- **Drink recipes get their own default icon** (🍹 instead of 🍽️) wherever a recipe has no cover photo.
+- **Explore now has a Type filter** (dish/drink), alongside My Recipes' existing one.
+
## 0.32.0 — 2026-07-14 17:10
### Security
diff --git a/apps/web/app/api/v1/ai/generate-from-idea/route.ts b/apps/web/app/api/v1/ai/generate-from-idea/route.ts
index e74491b..e4940bb 100644
--- a/apps/web/app/api/v1/ai/generate-from-idea/route.ts
+++ b/apps/web/app/api/v1/ai/generate-from-idea/route.ts
@@ -57,6 +57,7 @@ export async function POST(req: NextRequest) {
title: recipe.title,
description: recipe.description ?? null,
baseServings: recipe.baseServings,
+ recipeType: recipe.recipeType,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
difficulty: recipe.difficulty ?? null,
diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts
index 3cac04a..8348840 100644
--- a/apps/web/app/api/v1/ai/import-photo/route.ts
+++ b/apps/web/app/api/v1/ai/import-photo/route.ts
@@ -59,6 +59,7 @@ export async function POST(req: NextRequest) {
title: recipe.title,
description: recipe.description,
baseServings: recipe.baseServings ?? 4,
+ recipeType: recipe.recipeType,
visibility: "private",
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts
index 0f97465..3975524 100644
--- a/apps/web/app/api/v1/search/route.ts
+++ b/apps/web/app/api/v1/search/route.ts
@@ -91,6 +91,12 @@ export async function GET(req: NextRequest) {
conditions.push(eq(recipes.difficulty, difficulty));
}
+ const recipeTypeParam = searchParams.get("recipeType");
+ const recipeType = recipeTypeParam === "dish" || recipeTypeParam === "drink" ? recipeTypeParam : undefined;
+ if (recipeType) {
+ conditions.push(eq(recipes.recipeType, recipeType));
+ }
+
if (maxMins !== undefined) {
conditions.push(
sql`(${recipes.prepMins} + ${recipes.cookMins}) <= ${maxMins}`
diff --git a/apps/web/components/recipe/recipe-card.tsx b/apps/web/components/recipe/recipe-card.tsx
index be75b0e..d75bfc9 100644
--- a/apps/web/components/recipe/recipe-card.tsx
+++ b/apps/web/components/recipe/recipe-card.tsx
@@ -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 }) {
) : (
- 🍽️
+ {recipe.recipeType === "drink" ? "🍹" : "🍽️"}
)}
diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx
index 7c465a8..49f4026 100644
--- a/apps/web/components/recipe/recipes-grid.tsx
+++ b/apps/web/components/recipe/recipes-grid.tsx
@@ -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
/>
) : (
- 🍽️
+ {recipe.recipeType === "drink" ? "🍹" : "🍽️"}
)}
{selectMode && selected && }
diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx
index d155c5a..cb188b4 100644
--- a/apps/web/components/search/explore-page-content.tsx
+++ b/apps/web/components/search/explore-page-content.tsx
@@ -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>(new Set());
const [results, setResults] = useState([]);
@@ -115,7 +116,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags
const debounceRef = useRef | 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
{tRecipe("difficulty.hard")}
+
+