feat: adapt recipe surfaces for batch-cook sessions

- Recipes page action buttons reordered/restyled to push AI generation
  and batch cooking ahead of manual recipe creation.
- Recipe detail page hides meal/drink pairing (doesn't make sense for
  a multi-dish batch session).
- Print pages force light mode regardless of app theme (dark bg +
  hardcoded dark text was unreadable) and render sectioned steps +
  dishes/storage for batch-cook recipes.
- Markdown export mirrors the same sectioned structure.
- Cooking mode tags each step with which dish(es) it belongs to.
- Meal planner: mealPlanEntries.batchDishId lets a slot point at one
  specific dish within a batch session; picking a batch-cook recipe
  now prompts for which dish, and the grid shows the dish name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-12 02:32:57 +02:00
parent 8e83cc0dc7
commit ba1614073b
16 changed files with 4867 additions and 36 deletions
@@ -9,7 +9,7 @@ import { useLocale } from "@/lib/i18n/provider";
import { formatIngredientQuantity, type UnitPref } from "@/lib/unit-conversion";
import { useWakeLock } from "@/lib/hooks/use-wake-lock";
type Step = { id: string; instruction: string; timerSeconds: number | null };
type Step = { id: string; instruction: string; timerSeconds: number | null; appliesTo?: string[] };
type Ingredient = { rawName: string; quantity: string | null; unit: string | null };
type TimerState = {
@@ -287,6 +287,13 @@ export function CookingMode({
{t("stepOf", { current: current + 1, total: steps.length })}
</div>
{/* Batch-cook dish tag */}
{step.appliesTo !== undefined && (
<span className="text-xs font-semibold uppercase tracking-wide rounded-full bg-muted px-3 py-1">
{step.appliesTo.length === 0 ? t("batchPrepLabel") : step.appliesTo.join(" + ")}
</span>
)}
{/* Instruction */}
<p className="text-2xl sm:text-3xl leading-relaxed text-center font-medium max-w-2xl">
{step.instruction}
+59 -8
View File
@@ -15,16 +15,18 @@ type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
type Recipe = { id: string; title: string };
type BatchDish = { id: string; name: string };
type Entry = {
id: string;
day: Day;
mealType: MealType;
servings: number;
recipe: Recipe | null;
batchDish: BatchDish | null;
note: string | null;
};
type UserRecipe = { id: string; title: string };
type UserRecipe = { id: string; title: string; isBatchCook: boolean; batchDishes: BatchDish[] };
function AddEntryModal({
weekStart,
@@ -48,29 +50,72 @@ function AddEntryModal({
const [search, setSearch] = useState("");
const [servings, setServings] = useState("2");
const [saving, setSaving] = useState(false);
const [pickingDishFor, setPickingDishFor] = useState<UserRecipe | null>(null);
const t = useTranslations("mealPlan");
const filtered = userRecipes.filter((r) =>
r.title.toLowerCase().includes(search.toLowerCase())
);
async function add(recipe: UserRecipe) {
async function add(recipe: UserRecipe, batchDish?: BatchDish) {
setSaving(true);
try {
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ day, mealType, recipeId: recipe.id, servings: parseInt(servings) || 2 }),
body: JSON.stringify({
day,
mealType,
recipeId: recipe.id,
batchDishId: batchDish?.id,
servings: parseInt(servings) || 2,
}),
});
if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string };
onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, note: null });
onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, batchDish: batchDish ?? null, note: null });
onClose();
} finally {
setSaving(false);
}
}
function selectRecipe(recipe: UserRecipe) {
if (recipe.isBatchCook && recipe.batchDishes.length > 0) {
setPickingDishFor(recipe);
return;
}
void add(recipe);
}
if (pickingDishFor) {
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("pickDishTitle", { recipe: pickingDishFor.title })}</DialogTitle>
<DialogDescription>{t("pickDishDescription")}</DialogDescription>
</DialogHeader>
<div className="max-h-56 overflow-y-auto space-y-1">
{pickingDishFor.batchDishes.map((dish) => (
<button
key={dish.id}
onClick={() => { void add(pickingDishFor, dish); }}
disabled={saving}
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
>
{dish.name}
</button>
))}
</div>
<Button variant="ghost" size="sm" onClick={() => setPickingDishFor(null)} disabled={saving}>
{t("back")}
</Button>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
@@ -99,11 +144,16 @@ function AddEntryModal({
filtered.map((recipe) => (
<button
key={recipe.id}
onClick={() => add(recipe)}
onClick={() => selectRecipe(recipe)}
disabled={saving}
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
className="w-full flex items-center justify-between gap-2 text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
>
{recipe.title}
<span>{recipe.title}</span>
{recipe.isBatchCook && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground border rounded-full px-2 py-0.5">
{t("batchCookBadge")}
</span>
)}
</button>
))
)}
@@ -185,6 +235,7 @@ export function MealPlanner({
mealType: e.mealType as MealType,
servings: parseInt(aiServings) || 2,
recipe: { id: e.recipeId, title: e.recipeTitle },
batchDish: null,
note: null,
};
if (idx >= 0) updated[idx] = newEntry;
@@ -310,7 +361,7 @@ export function MealPlanner({
href={`/recipes/${entry.recipe.id}`}
className="block pr-8 hover:underline"
>
<div className="font-medium line-clamp-2">{entry.recipe.title}</div>
<div className="font-medium line-clamp-2">{entry.batchDish?.name ?? entry.recipe.title}</div>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1">
{t("servingsAbbrev", { count: entry.servings })}
{cookedIds.has(entry.id) && (
@@ -122,19 +122,19 @@ export function RecipesHeader({
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" className="gap-1.5" onClick={() => setAiOpen(true)}>
<Sparkles className="h-4 w-4" />
{t("generate")}
</Button>
<Link href="/batch-cooking" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
<ChefHat className="h-4 w-4" />
{t("batchCooking")}
</Link>
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setUrlOpen(true)}>
<Link2 className="h-4 w-4" />
{t("importUrl")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setAiOpen(true)}>
<Sparkles className="h-4 w-4" />
{t("generate")}
</Button>
<Link href="/recipes/new" className={cn(buttonVariants({ size: "sm" }))}>
<Link href="/recipes/new" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1.5")}>
<PlusCircle className="h-4 w-4" />
{t("new")}
</Link>