+ {/* Header */}
+
+
{recipe.title}
+
+
+
+
+ {recipe.visibility === "public" && (
+
+
+
+ )}
+ {recipe.ingredients.length > 0 && (
+
({
+ rawName: ing.rawName,
+ quantity: ing.quantity,
+ unit: ing.unit,
+ }))}
+ />
+ )}
+
+ {recipe.ingredients.length > 0 && (
+ ({ rawName: ing.rawName }))}
+ />
+ )}
+ ({
+ rawName: ing.rawName,
+ quantity: ing.quantity,
+ unit: ing.unit,
+ note: ing.note,
+ order: ing.order,
+ }))}
+ steps={recipe.steps.map((s) => ({
+ instruction: s.instruction,
+ timerSeconds: s.timerSeconds,
+ order: s.order,
+ }))}
+ />
+ {recipe.steps.length > 0 && (
+
+
+ Cook
+
+ )}
+
+
+
+
+ Edit
+
+
+
+
+ {avgScore !== null && (
+
+
+ {avgScore.toFixed(1)} ({ratingCount})
+
+ )}
+
+ {recipe.description && (
+
{recipe.description}
+ )}
+
+
+ {recipe.difficulty && (
+ {recipe.difficulty}
+ )}
+
+
+ {recipe.baseServings} servings
+
+ {recipe.prepMins && (
+
+
+ {recipe.prepMins}m prep
+
+ )}
+ {recipe.cookMins && (
+
+
+ {recipe.cookMins}m cook
+
+ )}
+ {totalMins > 0 && recipe.prepMins && recipe.cookMins && (
+ ({totalMins}m total)
+ )}
+
+
+ {VISIBILITY_LABEL[recipe.visibility]}
+
+
+
+ {activeDietaryTags.length > 0 && (
+
+ {activeDietaryTags.map((tag) => (
+ {tag}
+ ))}
+
+ )}
+
+
+ {/* Cover photo */}
+ {cover && (
+
+
})
+
+ )}
+
+
+
+ {/* Serving scaler + ingredients */}
+ {recipe.ingredients.length > 0 && (
+
+
Ingredients
+ ({
+ id: ing.id,
+ rawName: ing.rawName,
+ quantity: ing.quantity,
+ unit: ing.unit,
+ note: ing.note,
+ order: ing.order,
+ }))}
+ />
+
+
+ )}
+
+ {recipe.steps.length > 0 && (
+ <>
+
+
+
Instructions
+
+ {recipe.steps.map((step, i) => (
+ -
+
+ {i + 1}
+
+
+
{step.instruction}
+ {step.timerSeconds && (
+
+
+ {step.timerSeconds >= 60
+ ? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}`
+ : `${step.timerSeconds}s`}
+
+ )}
+
+
+ ))}
+
+
+ >
+ )}
+
+ {recipe.photos.length > 1 && (
+ <>
+
+
+
Photos
+
+ {recipe.photos.map((photo) => (
+
+
})
+
+ ))}
+
+
+ >
+ )}
+
+ {recipe.visibility !== "private" && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/app/(app)/recipes/[id]/print/page.tsx b/apps/web/app/(app)/recipes/[id]/print/page.tsx
new file mode 100644
index 0000000..54846cd
--- /dev/null
+++ b/apps/web/app/(app)/recipes/[id]/print/page.tsx
@@ -0,0 +1,142 @@
+import { notFound } from "next/navigation";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { db, recipes, eq, and } from "@epicure/db";
+import { PrintTrigger } from "@/components/recipe/print-trigger";
+
+type Params = { params: Promise<{ id: string }> };
+
+export default async function RecipePrintPage({ params }: Params) {
+ const { id } = await params;
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+
+ const recipe = await db.query.recipes.findFirst({
+ where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
+ with: {
+ ingredients: { orderBy: (t, { asc }) => asc(t.order) },
+ steps: { orderBy: (t, { asc }) => asc(t.order) },
+ },
+ });
+
+ if (!recipe) notFound();
+
+ const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
+
+ const DIETARY_LABELS: Record