Files
Epicure/apps/web/app/(app)/meal-plan/page.tsx
T
Arnaud 1677e40668 feat: copy/export as Markdown wherever print exists
Added a shared ExportMarkdownButton (copy to clipboard / download .md)
next to every existing print button: recipe, shopping list,
collection, meal plan, pantry. Each surface gets a small serializer in
lib/markdown/ built from data already in scope at that page — no new
queries except pantry, where items now thread through as a prop to
PantryPageHeader instead of being fetched only for PantryManager.

Also fixes an unrelated bug hit while verifying the collection export:
RecipeCard called the client-only useTranslations() hook without
"use client", so it rendered fine everywhere it happened to run inside
an already-client tree but 500'd — "Couldn't find next-intl config
file" — when Next tried to run it as a Server Component, which only
happens on the collection detail page (its only caller). Collections
with recipes in them were completely broken.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 22:15:48 +02:00

143 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, mealPlanMembers, recipes, eq, and, desc } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
import { cn } from "@/lib/utils";
import { ExportMarkdownButton } from "@/components/shared/export-markdown-button";
import { mealPlanToMarkdown } from "@/lib/markdown/meal-plan";
import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = { title: "Meal Plan" };
function getMonday(dateStr?: string): Date {
const d = dateStr ? new Date(dateStr) : new Date();
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function addWeeks(d: Date, n: number): Date {
const copy = new Date(d);
copy.setDate(copy.getDate() + n * 7);
return copy;
}
export default async function MealPlanPage({
searchParams,
}: {
searchParams: Promise<{ week?: string }>;
}) {
const { week } = await searchParams;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const msgs = getMessages((session.user as { locale?: string }).locale);
const monday = getMonday(week);
const weekStart = toDateStr(monday);
const prevWeek = toDateStr(addWeeks(monday, -1));
const nextWeek = toDateStr(addWeeks(monday, 1));
const sunday = addWeeks(monday, 1);
sunday.setDate(sunday.getDate() - 1);
const [plan, userRecipes, sharedMemberships] = await Promise.all([
db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: {
entries: {
with: { recipe: { with: { photos: true } } },
},
},
}),
db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id),
orderBy: desc(recipes.updatedAt),
columns: { id: true, title: true },
}),
db.query.mealPlanMembers.findMany({
where: eq(mealPlanMembers.userId, session.user.id),
with: { mealPlan: { with: { user: true } } },
}),
]);
const entries = (plan?.entries ?? []).map((e) => ({
id: e.id,
day: e.day,
mealType: e.mealType,
servings: e.servings,
note: e.note,
recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
}));
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{msgs.mealPlan.title}</h1>
<p className="text-muted-foreground mt-1">{label}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ShareMealPlanButton weekStart={weekStart} />
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ShoppingCart className="h-4 w-4" />
{msgs.mealPlan.shoppingLists}
</Link>
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
{msgs.common.print}
</Link>
<ExportMarkdownButton
markdown={mealPlanToMarkdown({ label, entries })}
filename={`meal-plan-${weekStart}`}
/>
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronLeft className="h-4 w-4" />
</Link>
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronRight className="h-4 w-4" />
</Link>
</div>
</div>
<WeeklyNutritionBar weekStart={weekStart} />
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} />
{sharedMemberships.length > 0 && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground">{msgs.mealPlan.sharedWithYou}</h2>
<div className="space-y-2 max-w-lg">
{sharedMemberships.map((membership) => (
<Link
key={membership.id}
href={`/meal-plan/shared/${membership.mealPlan.id}`}
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
>
<div>
<p className="font-medium">{formatMessage(msgs.mealPlan.sharedPlanOf, { name: membership.mealPlan.user?.name ?? "?" })}</p>
<p className="text-sm text-muted-foreground">
{formatMessage(msgs.mealPlan.weekOf, { date: membership.mealPlan.weekStart })} · {msgs.shareDialog[membership.role]}
</p>
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}