From 646c97128d044541f9914edc69f17e622538b002 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 12 Jul 2026 09:31:04 +0200 Subject: [PATCH] refactor: fold batch-cooking into the main recipes list and Generate dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch-cook recipes no longer live in a separate /batch-cooking section: - Removed the dedicated page/route and its nav button. - /recipes shows both kinds together, with a chef-hat badge marking batch sessions, and a filter to isolate/hide them. - "Batch cooking" is now a third tab in the existing "Generate recipe with AI" dialog (alongside Describe/Photo) instead of a separate button — one AI entry point instead of three. - Extracted the counters/difficulty/dietary form into BatchCookFields, shared between that dialog and the meal-planner's batch-cooking dialog. - Also fixed a dialog resize issue (progress bar mounting mid-generate grew the dialog's height, which the positioning library didn't always handle) by reserving space up front and capping dialog height with a scroll fallback. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(app)/batch-cooking/page.tsx | 31 ------ apps/web/app/(app)/recipes/page.tsx | 10 +- .../components/recipe/ai-generate-dialog.tsx | 76 +++++++++++--- .../components/recipe/batch-cook-fields.tsx | 99 +++++++++++++++++++ .../recipe/batch-cook-generate-dialog.tsx | 90 ++++------------- .../recipe/batch-cooking-page-content.tsx | 71 ------------- apps/web/components/recipe/recipes-grid.tsx | 22 +++-- apps/web/components/recipe/recipes-header.tsx | 33 +++++-- apps/web/messages/en.json | 3 + apps/web/messages/fr.json | 3 + 10 files changed, 238 insertions(+), 200 deletions(-) delete mode 100644 apps/web/app/(app)/batch-cooking/page.tsx create mode 100644 apps/web/components/recipe/batch-cook-fields.tsx delete mode 100644 apps/web/components/recipe/batch-cooking-page-content.tsx diff --git a/apps/web/app/(app)/batch-cooking/page.tsx b/apps/web/app/(app)/batch-cooking/page.tsx deleted file mode 100644 index 614f1bc..0000000 --- a/apps/web/app/(app)/batch-cooking/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Metadata } from "next"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth/server"; -import { db, recipes, eq, and, desc } from "@epicure/db"; -import { BatchCookingPageContent } from "@/components/recipe/batch-cooking-page-content"; - -export const metadata: Metadata = {}; - -export default async function BatchCookingPage() { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session) return null; - - const sessions = await db.query.recipes.findMany({ - where: and(eq(recipes.authorId, session.user.id), eq(recipes.isBatchCook, true)), - orderBy: desc(recipes.createdAt), - with: { batchDishes: true }, - }); - - return ( - ({ - id: r.id, - title: r.title, - description: r.description, - baseServings: r.baseServings, - createdAt: r.createdAt.toISOString(), - dishCount: r.batchDishes.length, - }))} - /> - ); -} diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index efce939..b1e3ec8 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -20,6 +20,7 @@ type SearchParams = Promise<{ difficulty?: string; tag?: string; page?: string; + batchCook?: string; }>; const SORT_MAP = { @@ -38,7 +39,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 } = await searchParams; + const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook } = 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; @@ -51,10 +52,11 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty) ? (difficulty as "easy" | "medium" | "hard") : undefined; + const batchCookFilter = batchCook === "1" || batchCook === "0" ? batchCook : undefined; const where = and( eq(recipes.authorId, session.user.id), - eq(recipes.isBatchCook, false), + batchCookFilter ? eq(recipes.isBatchCook, batchCookFilter === "1") : undefined, query ? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`)) : undefined, @@ -94,6 +96,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear if (visibilityFilter) params.set("visibility", visibilityFilter); if (difficultyFilter) params.set("difficulty", difficultyFilter); if (tagFilter) params.set("tag", tagFilter); + if (batchCookFilter) params.set("batchCook", batchCookFilter); if (p > 1) params.set("page", String(p)); const qs = params.toString(); return qs ? `/recipes?${qs}` : "/recipes"; @@ -114,9 +117,10 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear initialVisibility={visibilityFilter ?? ""} initialDifficulty={difficultyFilter ?? ""} initialTag={tagFilter ?? ""} + initialBatchCook={batchCookFilter ?? ""} /> - + {totalPages > 1 && (
diff --git a/apps/web/components/recipe/ai-generate-dialog.tsx b/apps/web/components/recipe/ai-generate-dialog.tsx index feb750f..370a198 100644 --- a/apps/web/components/recipe/ai-generate-dialog.tsx +++ b/apps/web/components/recipe/ai-generate-dialog.tsx @@ -4,7 +4,7 @@ import { useState, useRef } from "react"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { Sparkles, Loader2, Camera, Type, Upload, X, Shuffle } from "lucide-react"; +import { Sparkles, Loader2, Camera, Type, Upload, X, Shuffle, ChefHat } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -20,6 +20,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { cn } from "@/lib/utils"; import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; import { useLocale } from "@/lib/i18n/provider"; +import { BatchCookFields, type BatchCookFieldsState } from "./batch-cook-fields"; const LANGUAGES = [ { code: "en", label: "English" }, @@ -51,7 +52,7 @@ const SURPRISE_PROMPTS = [ "A refreshing no-cook meal for hot summer days", ]; -type Tab = "describe" | "photo"; +type Tab = "describe" | "photo" | "batch"; type GeneratedRecipe = { title: string; @@ -75,6 +76,7 @@ export function AiGenerateDialog({ const t = useTranslations("ai.generate"); const tCommon = useTranslations("common"); const tRecipe = useTranslations("recipe"); + const tBatch = useTranslations("batchCooking"); const router = useRouter(); const { locale } = useLocale(); const [tab, setTab] = useState("describe"); @@ -90,6 +92,15 @@ export function AiGenerateDialog({ const [photoMime, setPhotoMime] = useState("image/jpeg"); const fileRef = useRef(null); + // batch tab + const [batchFields, setBatchFields] = useState({ + dinners: 4, + lunches: 0, + servings: 4, + difficulty: "", + dietaryPrefs: "", + }); + const [busy, setBusy] = useState(false); function handleClose() { @@ -99,6 +110,7 @@ export function AiGenerateDialog({ setPrompt(""); setPhotoPreview(null); setPhotoBase64(null); + setBatchFields({ dinners: 4, lunches: 0, servings: 4, difficulty: "", dietaryPrefs: "" }); setTab("describe"); }, 200); } @@ -172,11 +184,39 @@ export function AiGenerateDialog({ } } - const canSubmit = tab === "describe" ? !!prompt.trim() : !!photoBase64; + async function handleBatchGenerate() { + setBusy(true); + try { + const res = await fetch("/api/v1/ai/batch-cook/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dinners: batchFields.dinners, + lunches: batchFields.lunches, + servings: batchFields.servings, + dietaryPrefs: batchFields.dietaryPrefs.trim() || undefined, + difficulty: batchFields.difficulty || undefined, + }), + }); + if (!res.ok) { + const err = await res.json() as { error?: string }; + toast.error(err.error ?? tBatch("generateFailed")); + return; + } + const { id } = await res.json() as { id: string }; + toast.success(tBatch("generateSuccess")); + handleClose(); + router.push(`/recipes/${id}`); + } finally { + setBusy(false); + } + } + + const canSubmit = tab === "describe" ? !!prompt.trim() : tab === "photo" ? !!photoBase64 : batchFields.dinners + batchFields.lunches > 0; return ( - + @@ -192,6 +232,7 @@ export function AiGenerateDialog({ {([ { key: "describe", label: t("tabDescribe"), icon: Type }, { key: "photo", label: t("tabPhoto"), icon: Camera }, + { key: "batch", label: tBatch("title"), icon: ChefHat }, ] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => (
diff --git a/apps/web/components/recipe/batch-cook-fields.tsx b/apps/web/components/recipe/batch-cook-fields.tsx new file mode 100644 index 0000000..bc3a59b --- /dev/null +++ b/apps/web/components/recipe/batch-cook-fields.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Minus, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +function Counter({ + value, + onChange, + max = 6, + disabled, +}: { + value: number; + onChange: (v: number) => void; + max?: number; + disabled?: boolean; +}) { + return ( +
+ + {value} + +
+ ); +} + +export type BatchCookFieldsState = { + dinners: number; + lunches: number; + servings: number; + difficulty: "" | "easy" | "medium" | "hard"; + dietaryPrefs: string; +}; + +export function BatchCookFields({ + state, + onChange, + disabled, +}: { + state: BatchCookFieldsState; + onChange: (state: BatchCookFieldsState) => void; + disabled?: boolean; +}) { + const t = useTranslations("batchCooking"); + const tCommon = useTranslations("common"); + const tRecipe = useTranslations("recipe"); + + return ( +
+
+ + onChange({ ...state, dinners: v })} disabled={disabled} /> +
+
+ + onChange({ ...state, lunches: v })} disabled={disabled} /> +
+
+ + onChange({ ...state, servings: v })} max={12} disabled={disabled} /> +
+
+ + +
+
+ + onChange({ ...state, dietaryPrefs: e.target.value })} + placeholder={t("dietaryPrefsPlaceholder")} + disabled={disabled} + /> +
+
+ ); +} diff --git a/apps/web/components/recipe/batch-cook-generate-dialog.tsx b/apps/web/components/recipe/batch-cook-generate-dialog.tsx index 9bb4ce6..21b655a 100644 --- a/apps/web/components/recipe/batch-cook-generate-dialog.tsx +++ b/apps/web/components/recipe/batch-cook-generate-dialog.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { ChefHat, Loader2, Minus, Plus } from "lucide-react"; +import { ChefHat, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -13,24 +13,8 @@ import { DialogTitle, DialogDescription, } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; - -function Counter({ value, onChange, max = 6 }: { value: number; onChange: (v: number) => void; max?: number }) { - return ( -
- - {value} - -
- ); -} +import { BatchCookFields, type BatchCookFieldsState } from "./batch-cook-fields"; export function BatchCookGenerateDialog({ open, @@ -41,14 +25,15 @@ export function BatchCookGenerateDialog({ }) { const t = useTranslations("batchCooking"); const tCommon = useTranslations("common"); - const tRecipe = useTranslations("recipe"); const router = useRouter(); - const [dinners, setDinners] = useState(4); - const [lunches, setLunches] = useState(0); - const [servings, setServings] = useState(4); - const [dietaryPrefs, setDietaryPrefs] = useState(""); - const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">(""); + const [fields, setFields] = useState({ + dinners: 4, + lunches: 0, + servings: 4, + difficulty: "", + dietaryPrefs: "", + }); const [busy, setBusy] = useState(false); function handleClose() { @@ -63,11 +48,11 @@ export function BatchCookGenerateDialog({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - dinners, - lunches, - servings, - dietaryPrefs: dietaryPrefs.trim() || undefined, - difficulty: difficulty || undefined, + dinners: fields.dinners, + lunches: fields.lunches, + servings: fields.servings, + dietaryPrefs: fields.dietaryPrefs.trim() || undefined, + difficulty: fields.difficulty || undefined, }), }); if (!res.ok) { @@ -84,11 +69,11 @@ export function BatchCookGenerateDialog({ } } - const canSubmit = dinners + lunches > 0; + const canSubmit = fields.dinners + fields.lunches > 0; return ( - + @@ -97,46 +82,11 @@ export function BatchCookGenerateDialog({ {t("wizardDescription")} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - setDietaryPrefs(e.target.value)} - placeholder={t("dietaryPrefsPlaceholder")} - disabled={busy} - /> -
-
+ - +
+ +
-
- - {sessions.length === 0 ? ( -
- -

{t("empty")}

- -
- ) : ( -
- {sessions.map((s) => ( - -

{s.title}

- {s.description &&

{s.description}

} -
- - - {t("dishCount", { count: s.dishCount })} - - {new Date(s.createdAt).toLocaleDateString(locale)} -
- - ))} -
- )} - - - - ); -} diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 5999798..3ee88e0 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect } from "react"; import Image from "next/image"; import { useTranslations } from "next-intl"; -import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus } from "lucide-react"; +import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus, ChefHat } from "lucide-react"; import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -45,6 +45,7 @@ type Recipe = { updatedAt: Date; photos?: Array<{ storageKey: string; isCover: boolean }>; isFavorited?: boolean; + isBatchCook?: boolean; }; /** Stops the click from bubbling to the card's own Link/select handler. */ @@ -114,9 +115,12 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: >
-

- {recipe.title} -

+
+

+ {recipe.title} +

+ {recipe.isBatchCook && } +
{recipe.description && (

{recipe.description}

)} @@ -171,7 +175,10 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b
-

{recipe.title}

+

+ {recipe.title} + {recipe.isBatchCook && } +

{!selectMode && ( @@ -211,7 +218,10 @@ function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected )} > -

{recipe.title}

+

+ {recipe.title} + {recipe.isBatchCook && } +

{recipe.baseServings} {totalMins > 0 && ( {t("total", { mins: totalMins })} diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index 13324bd..ece6407 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -3,7 +3,7 @@ import { useState, useTransition } from "react"; import Link from "next/link"; import { useRouter, usePathname } from "next/navigation"; -import { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown, ChefHat } from "lucide-react"; +import { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -59,6 +59,12 @@ const DIFFICULTY_KEYS: Record = { hard: "hard", }; +const BATCH_COOK_KEYS: Record = { + "": "all", + "1": "batchCookOnly", + "0": "batchCookExclude", +}; + export function RecipesHeader({ count, initialQuery = "", @@ -66,6 +72,7 @@ export function RecipesHeader({ initialVisibility = "", initialDifficulty = "", initialTag = "", + initialBatchCook = "", }: { count: number; initialQuery?: string; @@ -73,6 +80,7 @@ export function RecipesHeader({ initialVisibility?: string; initialDifficulty?: string; initialTag?: string; + initialBatchCook?: string; }) { const router = useRouter(); const pathname = usePathname(); @@ -91,6 +99,7 @@ export function RecipesHeader({ visibility: initialVisibility, difficulty: initialDifficulty, tag: initialTag, + batchCook: initialBatchCook, ...overrides, }; if (current.q?.trim()) params.set("q", current.q.trim()); @@ -98,6 +107,7 @@ export function RecipesHeader({ if (current.visibility) params.set("visibility", current.visibility); if (current.difficulty) params.set("difficulty", current.difficulty); if (current.tag?.trim()) params.set("tag", current.tag.trim()); + if (current.batchCook) params.set("batchCook", current.batchCook); startTransition(() => router.push(`${pathname}?${params.toString()}`)); } @@ -106,7 +116,7 @@ export function RecipesHeader({ pushParams({ q: value }); } - const activeFilterCount = [initialVisibility, initialDifficulty, initialTag].filter(Boolean).length; + const activeFilterCount = [initialVisibility, initialDifficulty, initialTag, initialBatchCook].filter(Boolean).length; const sortChanged = initialSort !== "updated_desc"; return ( @@ -126,10 +136,6 @@ export function RecipesHeader({ {t("generate")} - - - {t("batchCooking")} -
+ + + {t("batchCookLabel")} + {Object.entries(BATCH_COOK_KEYS).map(([value, key]) => ( + pushParams({ batchCook: value })} + className={cn(initialBatchCook === value && "font-medium text-primary")} + > + {t(key)} + + ))} + {activeFilterCount > 0 && ( <> pushParams({ visibility: "", difficulty: "", tag: "" })} + onClick={() => pushParams({ visibility: "", difficulty: "", tag: "", batchCook: "" })} className="text-muted-foreground" > {t("clearFilters")} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 0167277..811c307 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -592,6 +592,9 @@ "visibilityLabel": "Visibility", "difficultyLabel": "Difficulty", "tagLabel": "Tag", + "batchCookLabel": "Batch cooking", + "batchCookOnly": "Batch cooking only", + "batchCookExclude": "Hide batch cooking", "clearFilters": "Clear filters", "tabMine": "My Recipes", "tabFavorites": "Favorites", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 5e88c3e..46d6116 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -583,6 +583,9 @@ "visibilityLabel": "Visibilité", "difficultyLabel": "Difficulté", "tagLabel": "Tag", + "batchCookLabel": "Batch cooking", + "batchCookOnly": "Batch cooking uniquement", + "batchCookExclude": "Masquer le batch cooking", "clearFilters": "Effacer les filtres", "tabMine": "Mes recettes", "tabFavorites": "Favoris",