feat: recipe type (dish/drink) for better cocktail handling (v0.31.0)
Add recipes.recipeType enum (dish|drink, default dish). Recipe form now shows a Type selector and hides food-only fields for drinks (prep/cook time, difficulty, batch-cook toggle) instead of forcing every cocktail through meal-oriented UI. Detail page skips the meal/drink pairing suggestion buttons on drink recipes (pairing a drink with a drink doesn't make sense). Recipes list gains a Type filter facet alongside difficulty/batch-cook. Scoped deliberately narrow per investigation: no ABV/glass-type/ garnish structured fields, no meal-plan schema changes, and the existing AI drink-pairing-suggestion feature (ephemeral, never saved as a recipe) is left as-is rather than wired into recipe creation — those suggestions are wine/beer/spirit recommendations without ingredients/steps, not really recipes to save. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ export default async function EditRecipePage({ params }: Params) {
|
||||
title: recipe.title,
|
||||
description: recipe.description ?? undefined,
|
||||
baseServings: recipe.baseServings,
|
||||
recipeType: recipe.recipeType,
|
||||
visibility: recipe.visibility,
|
||||
difficulty: recipe.difficulty,
|
||||
prepMins: recipe.prepMins,
|
||||
|
||||
@@ -166,7 +166,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
</Tooltip>
|
||||
)}
|
||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||
{!recipe.isBatchCook && (
|
||||
{!recipe.isBatchCook && recipe.recipeType !== "drink" && (
|
||||
<>
|
||||
<MealPairingButton recipeId={id} />
|
||||
<DrinkPairingButton recipeId={id} />
|
||||
|
||||
@@ -22,6 +22,7 @@ type SearchParams = Promise<{
|
||||
tag?: string;
|
||||
page?: string;
|
||||
batchCook?: string;
|
||||
recipeType?: string;
|
||||
}>;
|
||||
|
||||
const SORT_MAP = {
|
||||
@@ -40,7 +41,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook } = await searchParams;
|
||||
const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType } = await searchParams;
|
||||
const query = (q ?? "").trim().slice(0, 200);
|
||||
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
|
||||
const tagFilter = tag?.trim().slice(0, 50) || undefined;
|
||||
@@ -54,6 +55,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
? (difficulty as "easy" | "medium" | "hard")
|
||||
: undefined;
|
||||
const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined;
|
||||
const recipeTypeFilter = recipeType === "dish" || recipeType === "drink" ? recipeType : undefined;
|
||||
|
||||
// Escape ilike wildcard chars (% and _) so a search like "100%" matches literally.
|
||||
const escapedQuery = query.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
@@ -78,6 +80,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
|
||||
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
|
||||
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
|
||||
recipeTypeFilter ? eq(recipes.recipeType, recipeTypeFilter) : undefined,
|
||||
);
|
||||
|
||||
const [userRecipes, totalRow] = await Promise.all([
|
||||
@@ -112,6 +115,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
if (difficultyFilter) params.set("difficulty", difficultyFilter);
|
||||
if (tagFilter) params.set("tag", tagFilter);
|
||||
if (batchCookFilter) params.set("batchCook", batchCookFilter);
|
||||
if (recipeTypeFilter) params.set("recipeType", recipeTypeFilter);
|
||||
if (p > 1) params.set("page", String(p));
|
||||
const qs = params.toString();
|
||||
return qs ? `/recipes?${qs}` : "/recipes";
|
||||
@@ -133,9 +137,10 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
initialDifficulty={difficultyFilter ?? ""}
|
||||
initialTag={tagFilter ?? ""}
|
||||
initialBatchCook={batchCookFilter ?? ""}
|
||||
initialRecipeType={recipeTypeFilter ?? ""}
|
||||
/>
|
||||
<RecipesEmptyState query={query} count={total} />
|
||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${page}`} recipes={recipesWithFavorites} />
|
||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${batchCookFilter}-${recipeTypeFilter}-${page}`} recipes={recipesWithFavorites} />
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ const UpdateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
baseServings: z.number().int().min(1).max(100).optional(),
|
||||
recipeType: z.enum(["dish", "drink"]).optional(),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
||||
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||
@@ -150,6 +151,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
if (data.title !== undefined) updates.title = data.title;
|
||||
if (data.description !== undefined) updates.description = data.description;
|
||||
if (data.baseServings !== undefined) updates.baseServings = data.baseServings;
|
||||
if (data.recipeType !== undefined) updates.recipeType = data.recipeType;
|
||||
if (data.visibility !== undefined) updates.visibility = data.visibility;
|
||||
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
|
||||
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
|
||||
|
||||
@@ -13,6 +13,7 @@ const CreateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
baseServings: z.number().int().min(1).max(100).default(4),
|
||||
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
prepMins: z.number().int().min(0).max(1440).optional(),
|
||||
@@ -69,9 +70,11 @@ export async function GET(req: NextRequest) {
|
||||
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100);
|
||||
const offset = parseInt(searchParams.get("offset") ?? "0");
|
||||
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | null;
|
||||
const recipeType = searchParams.get("recipeType") as "dish" | "drink" | null;
|
||||
|
||||
const conditions = [eq(recipes.authorId, session!.user.id)];
|
||||
if (visibility) conditions.push(eq(recipes.visibility, visibility));
|
||||
if (recipeType) conditions.push(eq(recipes.recipeType, recipeType));
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -118,6 +121,7 @@ export async function POST(req: NextRequest) {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
baseServings: data.baseServings,
|
||||
recipeType: data.recipeType,
|
||||
visibility: data.visibility,
|
||||
difficulty: data.difficulty,
|
||||
prepMins: data.prepMins,
|
||||
|
||||
Reference in New Issue
Block a user