9c545a5bb3
- Private accounts: users.isPrivate hides a user from search and their recipes from search/trending/for-you discovery surfaces (follow-aware where the route already has session context, blanket exclusion where it doesn't); existing followers and direct links are unaffected, no follow-request approval flow was built (explicit scope limit) - Merged /people into the Explore tab (tab=people query param); the old standalone route now redirects there - "Get Ideas" vs "Surprise Me" were doing the same empty-prompt call; Surprise Me now injects a real random constraint (5-ingredient, one-pot, etc.), matching the existing pattern in the AI recipe-generate dialog - Meal-plan day cells now link to their recipe (was dead text) and gained a one-click "mark as cooked" action - Theme toggle is now a real three-way light/dark/system control instead of a binary flip - Nutrition goals form had zero i18n wiring; fully localized now New migration 0027 (users.is_private) generated, left unapplied like the others. Verified with typecheck, lint, and a clean --no-cache docker build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
488 lines
19 KiB
TypeScript
488 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback } 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 { 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";
|
|
|
|
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
|
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
|
|
|
|
type Recipe = { id: string; title: string };
|
|
type Entry = {
|
|
id: string;
|
|
day: Day;
|
|
mealType: MealType;
|
|
servings: number;
|
|
recipe: Recipe | null;
|
|
note: string | null;
|
|
};
|
|
|
|
type UserRecipe = { id: string; title: string };
|
|
|
|
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 t = useTranslations("mealPlan");
|
|
|
|
const filtered = userRecipes.filter((r) =>
|
|
r.title.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
async function add(recipe: UserRecipe) {
|
|
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 }),
|
|
});
|
|
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 });
|
|
onClose();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
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={() => add(recipe)}
|
|
disabled={saving}
|
|
className="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition-colors"
|
|
>
|
|
{recipe.title}
|
|
</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 [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 t = useTranslations("mealPlan");
|
|
const tRecipe = useTranslations("recipe");
|
|
|
|
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 },
|
|
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) => {
|
|
setEntries((prev) => [
|
|
...prev.filter((e) => !(e.day === entry.day && e.mealType === entry.mealType)),
|
|
entry,
|
|
]);
|
|
}, []);
|
|
|
|
async function removeEntry(entry: Entry) {
|
|
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 {
|
|
toast.error(t("removeFailed"));
|
|
}
|
|
}
|
|
|
|
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 button */}
|
|
<div className="flex justify-end mb-4">
|
|
<Button variant="ghost" 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 }) => (
|
|
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">{label}</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.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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|