00ca8b9d68
Two separate components (the owner's own week view and collaborators' shared view) hit two different API surfaces for the same underlying entries, so neither ever saw the other's edits without a manual reload. Both now poll a new lean GET on their respective entries routes every 4s, merged with the same dirty-until-ref guard used for shopping lists so a poll can't stomp an in-flight local edit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
668 lines
26 KiB
TypeScript
668 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import Link from "next/link";
|
|
import { Plus, Trash2, Sparkles, ChefHat, Check } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useTranslations } from "next-intl";
|
|
import { BatchCookGenerateDialog } from "@/components/recipe/batch-cook-generate-dialog";
|
|
|
|
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; isBatchCook: boolean; batchDishes: BatchDish[] };
|
|
|
|
function AddEntryModal({
|
|
weekStart,
|
|
day,
|
|
mealType,
|
|
userRecipes,
|
|
onAdded,
|
|
onClose,
|
|
dayLabel,
|
|
mealLabel,
|
|
}: {
|
|
weekStart: string;
|
|
day: Day;
|
|
mealType: MealType;
|
|
userRecipes: UserRecipe[];
|
|
onAdded: (entry: Entry) => void;
|
|
onClose: () => void;
|
|
dayLabel: string;
|
|
mealLabel: string;
|
|
}) {
|
|
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, 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,
|
|
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, 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">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("addTo", { day: dayLabel, meal: mealLabel })}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<Input
|
|
placeholder={t("searchRecipes")}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={servings}
|
|
onChange={(e) => setServings(e.target.value)}
|
|
placeholder={t("servings")}
|
|
/>
|
|
<div className="max-h-56 overflow-y-auto space-y-1">
|
|
{filtered.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-4 text-center">{t("noRecipes")}</p>
|
|
) : (
|
|
filtered.map((recipe) => (
|
|
<button
|
|
key={recipe.id}
|
|
onClick={() => selectRecipe(recipe)}
|
|
disabled={saving}
|
|
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"
|
|
>
|
|
<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>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function MealPlanner({
|
|
weekStart,
|
|
initialEntries,
|
|
userRecipes,
|
|
hasNutritionGoals = false,
|
|
}: {
|
|
weekStart: string;
|
|
initialEntries: Entry[];
|
|
userRecipes: UserRecipe[];
|
|
hasNutritionGoals?: boolean;
|
|
}) {
|
|
const [entries, setEntries] = useState<Entry[]>(initialEntries);
|
|
const [cookedIds, setCookedIds] = useState<Set<string>>(new Set());
|
|
const [markingCookedIds, setMarkingCookedIds] = useState<Set<string>>(new Set());
|
|
const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
|
|
const [showAiModal, setShowAiModal] = useState(false);
|
|
const [showBatchModal, setShowBatchModal] = useState(false);
|
|
const [aiGenerating, setAiGenerating] = useState(false);
|
|
const [aiPhase, setAiPhase] = useState("");
|
|
const [dietaryPrefs, setDietaryPrefs] = useState("");
|
|
const [aiServings, setAiServings] = useState("2");
|
|
const [usePantry, setUsePantry] = useState(true);
|
|
const [pantryMode, setPantryMode] = useState(false);
|
|
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
|
const [targetNutritionGoals, setTargetNutritionGoals] = useState(false);
|
|
const [clearTarget, setClearTarget] = useState<{ type: "day"; day: Day; label: string } | { type: "week" } | null>(null);
|
|
const [clearing, setClearing] = useState(false);
|
|
const t = useTranslations("mealPlan");
|
|
const tRecipe = useTranslations("recipe");
|
|
const tCommon = useTranslations("common");
|
|
|
|
// Live updates: a collaborator can edit this same plan through the shared
|
|
// view (a different URL/component) — poll so those changes show up here
|
|
// without a manual refresh, same pattern as shopping-list-view.tsx.
|
|
const dirtyUntilRef = useRef<Map<string, number>>(new Map());
|
|
const pendingDeleteIdsRef = useRef<Set<string>>(new Set());
|
|
const DIRTY_MS = 4000;
|
|
|
|
function markDirty(...ids: string[]) {
|
|
const until = Date.now() + DIRTY_MS;
|
|
for (const id of ids) dirtyUntilRef.current.set(id, until);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
fetch(`/api/v1/meal-plans/${weekStart}/entries`)
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((data: { entries: Entry[] } | null) => {
|
|
if (!data) return;
|
|
const now = Date.now();
|
|
setEntries((prev) => {
|
|
const prevById = new Map(prev.map((e) => [e.id, e]));
|
|
return data.entries
|
|
.filter((s) => !pendingDeleteIdsRef.current.has(s.id))
|
|
.map((s) => {
|
|
const dirty = (dirtyUntilRef.current.get(s.id) ?? 0) > now;
|
|
return dirty ? (prevById.get(s.id) ?? s) : s;
|
|
});
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
}, 4000);
|
|
return () => clearInterval(interval);
|
|
}, [weekStart]);
|
|
|
|
async function generateWithAi() {
|
|
setAiGenerating(true);
|
|
setAiPhase(t("aiPhaseAnalyzing"));
|
|
const phases = [
|
|
[1500, t("aiPhasePlanning")],
|
|
[5000, t("aiPhaseSelecting")],
|
|
[10000, t("aiPhaseBalancing")],
|
|
[16000, t("aiPhaseFinalizing")],
|
|
] as const;
|
|
const timers = phases.map(([delay, label]) =>
|
|
setTimeout(() => setAiPhase(label), delay)
|
|
);
|
|
try {
|
|
const res = await fetch("/api/v1/ai/meal-plan/generate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
weekStart,
|
|
dietaryPrefs: dietaryPrefs.trim() || undefined,
|
|
servings: parseInt(aiServings) || 2,
|
|
usePantry,
|
|
pantryMode,
|
|
difficulty: aiDifficulty || undefined,
|
|
targetNutritionGoals: hasNutritionGoals && targetNutritionGoals,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json() as { error?: string };
|
|
throw new Error(data.error ?? t("generationFailed"));
|
|
}
|
|
const data = await res.json() as {
|
|
entries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }>;
|
|
};
|
|
// Merge generated entries into state
|
|
setEntries((prev) => {
|
|
const updated = [...prev];
|
|
for (const e of data.entries) {
|
|
const idx = updated.findIndex((u) => u.day === e.day && u.mealType === e.mealType);
|
|
const newEntry: Entry = {
|
|
id: e.id,
|
|
day: e.day as Day,
|
|
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;
|
|
else updated.push(newEntry);
|
|
}
|
|
return updated;
|
|
});
|
|
setShowAiModal(false);
|
|
toast.success(t("aiGenerated"));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t("generationFailed"));
|
|
} finally {
|
|
timers.forEach(clearTimeout);
|
|
setAiGenerating(false);
|
|
setAiPhase("");
|
|
}
|
|
}
|
|
|
|
const DAYS: { key: Day; label: string }[] = [
|
|
{ key: "mon", label: t("days.mon") },
|
|
{ key: "tue", label: t("days.tue") },
|
|
{ key: "wed", label: t("days.wed") },
|
|
{ key: "thu", label: t("days.thu") },
|
|
{ key: "fri", label: t("days.fri") },
|
|
{ key: "sat", label: t("days.sat") },
|
|
{ key: "sun", label: t("days.sun") },
|
|
];
|
|
|
|
const MEAL_TYPES: { key: MealType; label: string }[] = [
|
|
{ key: "breakfast", label: t("meals.breakfast") },
|
|
{ key: "lunch", label: t("meals.lunch") },
|
|
{ key: "dinner", label: t("meals.dinner") },
|
|
{ key: "snack", label: t("meals.snack") },
|
|
];
|
|
|
|
const getEntry = (day: Day, mealType: MealType) =>
|
|
entries.find((e) => e.day === day && e.mealType === mealType);
|
|
|
|
const handleAdded = useCallback((entry: Entry) => {
|
|
markDirty(entry.id);
|
|
setEntries((prev) => [
|
|
...prev.filter((e) => !(e.day === entry.day && e.mealType === entry.mealType)),
|
|
entry,
|
|
]);
|
|
}, []);
|
|
|
|
async function removeEntry(entry: Entry) {
|
|
pendingDeleteIdsRef.current.add(entry.id);
|
|
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/${entry.id}`, { method: "DELETE" });
|
|
if (res.ok) {
|
|
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
|
|
} else {
|
|
pendingDeleteIdsRef.current.delete(entry.id);
|
|
toast.error(t("removeFailed"));
|
|
}
|
|
}
|
|
|
|
async function confirmClear() {
|
|
if (!clearTarget) return;
|
|
const ids = (
|
|
clearTarget.type === "day" ? entries.filter((e) => e.day === clearTarget.day) : entries
|
|
).map((e) => e.id);
|
|
if (ids.length === 0) { setClearTarget(null); return; }
|
|
|
|
for (const id of ids) pendingDeleteIdsRef.current.add(id);
|
|
setClearing(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/bulk`, {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
if (!res.ok) {
|
|
for (const id of ids) pendingDeleteIdsRef.current.delete(id);
|
|
toast.error(t("removeFailed"));
|
|
return;
|
|
}
|
|
setEntries((prev) => prev.filter((e) => !ids.includes(e.id)));
|
|
toast.success(t("clearedSuccess", { count: ids.length }));
|
|
} finally {
|
|
setClearing(false);
|
|
setClearTarget(null);
|
|
}
|
|
}
|
|
|
|
async function markCooked(entry: Entry) {
|
|
if (!entry.recipe || markingCookedIds.has(entry.id)) return;
|
|
setMarkingCookedIds((prev) => new Set(prev).add(entry.id));
|
|
try {
|
|
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ servings: entry.servings, deductFromPantry: false }),
|
|
});
|
|
if (!res.ok) {
|
|
toast.error(t("markCookedFailed"));
|
|
return;
|
|
}
|
|
setCookedIds((prev) => new Set(prev).add(entry.id));
|
|
toast.success(t("markCookedSuccess"));
|
|
} catch {
|
|
toast.error(t("markCookedFailed"));
|
|
} finally {
|
|
setMarkingCookedIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(entry.id);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
const addingDay = adding ? DAYS.find((d) => d.key === adding.day) : null;
|
|
const addingMeal = adding ? MEAL_TYPES.find((m) => m.key === adding.mealType) : null;
|
|
|
|
return (
|
|
<>
|
|
{/* AI Generate buttons */}
|
|
<div className="flex justify-end gap-2 mb-4">
|
|
{entries.length > 0 && (
|
|
<Button variant="ghost" size="sm" onClick={() => setClearTarget({ type: "week" })} className="gap-2 text-muted-foreground">
|
|
<Trash2 className="h-4 w-4" />
|
|
{t("clearWeek")}
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="sm" onClick={() => setShowBatchModal(true)} className="gap-2">
|
|
<ChefHat className="h-4 w-4" />
|
|
{t("batchCooking")}
|
|
</Button>
|
|
<Button size="sm" onClick={() => setShowAiModal(true)} className="gap-2">
|
|
<Sparkles className="h-4 w-4" />
|
|
{t("generateWithAi")}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[640px] border-collapse table-fixed">
|
|
<colgroup>
|
|
<col className="w-24" />
|
|
{DAYS.map(({ key }) => (
|
|
<col key={key} />
|
|
))}
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
|
|
{DAYS.map(({ key, label }) => {
|
|
const dayHasEntries = entries.some((e) => e.day === key);
|
|
return (
|
|
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">
|
|
<span className="group inline-flex items-center gap-1">
|
|
{label}
|
|
{dayHasEntries && (
|
|
<button
|
|
onClick={() => setClearTarget({ type: "day", day: key, label })}
|
|
title={t("clearDay")}
|
|
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</span>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{MEAL_TYPES.map(({ key: mealType, label }) => (
|
|
<tr key={mealType} className="border-t">
|
|
<td className="py-3 pr-3">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</span>
|
|
</td>
|
|
{DAYS.map(({ key: day }) => {
|
|
const entry = getEntry(day, mealType);
|
|
return (
|
|
<td key={day} className="py-2 px-1 align-top">
|
|
{entry?.recipe ? (
|
|
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
|
|
<Link
|
|
href={`/recipes/${entry.recipe.id}`}
|
|
className="block pr-8 hover:underline"
|
|
>
|
|
<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) && (
|
|
<Check className="h-3 w-3 text-green-600 shrink-0" />
|
|
)}
|
|
</div>
|
|
</Link>
|
|
<div className="absolute top-1 right-1 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => markCooked(entry)}
|
|
disabled={markingCookedIds.has(entry.id)}
|
|
title={t("markCooked")}
|
|
className="text-muted-foreground hover:text-primary disabled:opacity-50"
|
|
>
|
|
<ChefHat className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => removeEntry(entry)}
|
|
title={t("removeEntry")}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setAdding({ day, mealType })}
|
|
className="w-full min-h-[52px] rounded-lg border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-muted-foreground/40" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{adding && addingDay && addingMeal && (
|
|
<AddEntryModal
|
|
weekStart={weekStart}
|
|
day={adding.day}
|
|
mealType={adding.mealType}
|
|
userRecipes={userRecipes}
|
|
onAdded={handleAdded}
|
|
onClose={() => setAdding(null)}
|
|
dayLabel={addingDay.label}
|
|
mealLabel={addingMeal.label}
|
|
/>
|
|
)}
|
|
|
|
{/* AI generation modal */}
|
|
{showAiModal && (
|
|
<Dialog open onOpenChange={(open) => { if (!aiGenerating) setShowAiModal(open); }}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4" />
|
|
{t("generateWithAi")}
|
|
</DialogTitle>
|
|
<DialogDescription>{t("aiModalDescription")}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">{t("dietaryPrefs")}</label>
|
|
<Input
|
|
placeholder={t("dietaryPrefsPlaceholder")}
|
|
value={dietaryPrefs}
|
|
onChange={(e) => setDietaryPrefs(e.target.value)}
|
|
disabled={aiGenerating}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">{t("servings")}</label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={20}
|
|
value={aiServings}
|
|
onChange={(e) => setAiServings(e.target.value)}
|
|
disabled={aiGenerating}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium">{t("difficulty")}</label>
|
|
<Select value={aiDifficulty} onValueChange={(v) => setAiDifficulty(v as typeof aiDifficulty)} disabled={aiGenerating}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("anyDifficulty")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
|
|
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
|
|
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
|
|
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4"
|
|
checked={usePantry}
|
|
onChange={(e) => {
|
|
setUsePantry(e.target.checked);
|
|
if (!e.target.checked) setPantryMode(false);
|
|
}}
|
|
disabled={aiGenerating}
|
|
/>
|
|
<span className="text-sm font-medium">{t("includePantryItems")}</span>
|
|
</label>
|
|
{usePantry && (
|
|
<label className="flex items-start gap-2 cursor-pointer select-none pl-6">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 mt-0.5"
|
|
checked={pantryMode}
|
|
onChange={(e) => setPantryMode(e.target.checked)}
|
|
disabled={aiGenerating}
|
|
/>
|
|
<span className="space-y-0.5">
|
|
<span className="text-sm font-medium block">{t("pantryMode")}</span>
|
|
<span className="text-sm text-muted-foreground block">{t("pantryModeDescription")}</span>
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
{hasNutritionGoals && (
|
|
<label className="flex items-start gap-2 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 mt-0.5"
|
|
checked={targetNutritionGoals}
|
|
onChange={(e) => setTargetNutritionGoals(e.target.checked)}
|
|
disabled={aiGenerating}
|
|
/>
|
|
<span className="space-y-0.5">
|
|
<span className="text-sm font-medium block">{t("targetNutritionGoals")}</span>
|
|
<span className="text-sm text-muted-foreground block">{t("targetNutritionGoalsDescription")}</span>
|
|
</span>
|
|
</label>
|
|
)}
|
|
<FakeProgressBar active={aiGenerating} durationMs={20000} label={aiPhase} />
|
|
<div className="flex gap-2 justify-end">
|
|
<Button variant="ghost" onClick={() => setShowAiModal(false)} disabled={aiGenerating}>
|
|
{t("cancel")}
|
|
</Button>
|
|
<Button onClick={() => { void generateWithAi(); }} disabled={aiGenerating} className="gap-2">
|
|
{aiGenerating ? (
|
|
<>
|
|
<span className="animate-spin inline-block h-3.5 w-3.5 border-2 border-current border-t-transparent rounded-full" />
|
|
{t("generating")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
{t("generate")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
<BatchCookGenerateDialog open={showBatchModal} onOpenChange={setShowBatchModal} />
|
|
|
|
<AlertDialog open={clearTarget !== null} onOpenChange={(open) => !open && setClearTarget(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{clearTarget?.type === "day" ? t("clearDayConfirmTitle", { day: clearTarget.label }) : t("clearWeekConfirmTitle")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>{t("clearConfirmDescription")}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={clearing}>{tCommon("cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={clearing}
|
|
onClick={() => { void confirmClear(); }}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{tCommon("delete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|