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:
@@ -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) && (
|
||||
|
||||
Reference in New Issue
Block a user