1677e40668
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>
143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
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>
|
||
);
|
||
}
|