diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index a9c0845..ea5ee1b 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -10,6 +10,8 @@ import { ForkCollectionButton } from "@/components/collections/fork-collection-b import { ShareCollectionButton } from "@/components/collections/share-collection-button"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; +import { collectionToMarkdown } from "@/lib/markdown/collection"; import { getMessages } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -46,10 +48,20 @@ export default async function CollectionPage({ params }: Params) {
{col.recipes.length > 0 && ( - - - {m.collections.exportPdf} - + <> + + + {m.collections.exportPdf} + + (r.recipe ? [r.recipe] : [])), + })} + filename={col.name} + /> + )} {isOwner && } {!isOwner && col.isPublic && ( diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index 90ff5cd..26bab55 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -9,6 +9,8 @@ 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" }; @@ -98,6 +100,10 @@ export default async function MealPlanPage({ {msgs.common.print} + diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx index 2c1cbc0..c66e4ba 100644 --- a/apps/web/app/(app)/pantry/page.tsx +++ b/apps/web/app/(app)/pantry/page.tsx @@ -16,16 +16,18 @@ export default async function PantryPage() { orderBy: asc(pantryItems.rawName), }); + const mappedItems = items.map((i) => ({ + id: i.id, + rawName: i.rawName, + quantity: i.quantity, + unit: i.unit, + expiresAt: i.expiresAt?.toISOString() ?? null, + })); + return (
- - ({ - id: i.id, - rawName: i.rawName, - quantity: i.quantity, - unit: i.unit, - expiresAt: i.expiresAt?.toISOString() ?? null, - }))} /> + +
); } diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index ecac380..5c53a72 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -31,6 +31,8 @@ import { getPublicUrl } from "@/lib/storage"; import { cn } from "@/lib/utils"; import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel"; import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake"; +import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; +import { recipeToMarkdown } from "@/lib/markdown/recipe"; import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -172,6 +174,20 @@ export default async function RecipePage({ params }: Params) { /> + {isOwner && ( <> }; @@ -52,6 +54,10 @@ export default async function ShoppingListPage({ params }: Params) { {m.common.print} +
{tCommon("print")} + ); diff --git a/apps/web/components/recipe/recipe-card.tsx b/apps/web/components/recipe/recipe-card.tsx index 60e7baa..e464923 100644 --- a/apps/web/components/recipe/recipe-card.tsx +++ b/apps/web/components/recipe/recipe-card.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { useTranslations } from "next-intl"; import { Clock, Users, Lock, Globe, Link2 } from "lucide-react"; diff --git a/apps/web/components/shared/export-markdown-button.tsx b/apps/web/components/shared/export-markdown-button.tsx new file mode 100644 index 0000000..6009ffc --- /dev/null +++ b/apps/web/components/shared/export-markdown-button.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Copy, Download, FileDown } from "lucide-react"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function ExportMarkdownButton({ + markdown, + filename, +}: { + markdown: string; + filename: string; +}) { + const t = useTranslations("common"); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(markdown); + toast.success(t("copiedToClipboard")); + } catch { + toast.error(t("copyFailed")); + } + } + + function handleDownload() { + const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename.endsWith(".md") ? filename : `${filename}.md`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + return ( + + + + + } /> + + { void handleCopy(); }}> + + {t("copyMarkdown")} + + + + {t("downloadMarkdown")} + + + + ); +} diff --git a/apps/web/lib/markdown/collection.ts b/apps/web/lib/markdown/collection.ts new file mode 100644 index 0000000..5763dc3 --- /dev/null +++ b/apps/web/lib/markdown/collection.ts @@ -0,0 +1,32 @@ +type CollectionMarkdownInput = { + name: string; + description: string | null; + recipes: Array<{ + title: string; + description: string | null; + baseServings: number; + prepMins: number | null; + cookMins: number | null; + difficulty: "easy" | "medium" | "hard" | null; + }>; +}; + +export function collectionToMarkdown(collection: CollectionMarkdownInput): string { + const lines: string[] = [`# ${collection.name}`, ""]; + + if (collection.description) { + lines.push(collection.description, ""); + } + + for (const recipe of collection.recipes) { + lines.push(`## ${recipe.title}`, ""); + if (recipe.description) lines.push(recipe.description, ""); + const meta: string[] = [`Servings: ${recipe.baseServings}`]; + if (recipe.prepMins) meta.push(`Prep: ${recipe.prepMins} min`); + if (recipe.cookMins) meta.push(`Cook: ${recipe.cookMins} min`); + if (recipe.difficulty) meta.push(`Difficulty: ${recipe.difficulty}`); + lines.push(meta.join(" · "), ""); + } + + return lines.join("\n").trim() + "\n"; +} diff --git a/apps/web/lib/markdown/meal-plan.ts b/apps/web/lib/markdown/meal-plan.ts new file mode 100644 index 0000000..4bacf49 --- /dev/null +++ b/apps/web/lib/markdown/meal-plan.ts @@ -0,0 +1,33 @@ +type MealPlanMarkdownInput = { + label: string; + entries: Array<{ + day: string; + mealType: string; + servings: number; + note: string | null; + recipe: { title: string } | null; + }>; +}; + +export function mealPlanToMarkdown(plan: MealPlanMarkdownInput): string { + const lines: string[] = [`# Meal Plan — ${plan.label}`, ""]; + + const byDay = new Map(); + for (const entry of plan.entries) { + const group = byDay.get(entry.day) ?? []; + group.push(entry); + byDay.set(entry.day, group); + } + + for (const [day, entries] of byDay) { + lines.push(`## ${day}`, ""); + for (const entry of entries) { + const title = entry.recipe?.title ?? "(no recipe)"; + const note = entry.note ? ` — ${entry.note}` : ""; + lines.push(`- **${entry.mealType}**: ${title} (${entry.servings} servings)${note}`); + } + lines.push(""); + } + + return lines.join("\n").trim() + "\n"; +} diff --git a/apps/web/lib/markdown/pantry.ts b/apps/web/lib/markdown/pantry.ts new file mode 100644 index 0000000..da32da2 --- /dev/null +++ b/apps/web/lib/markdown/pantry.ts @@ -0,0 +1,15 @@ +type PantryMarkdownInput = { + items: Array<{ rawName: string; quantity: string | null; unit: string | null; expiresAt: string | null }>; +}; + +export function pantryToMarkdown(pantry: PantryMarkdownInput): string { + const lines: string[] = ["# Pantry", ""]; + + for (const item of pantry.items) { + const qty = [item.quantity, item.unit].filter(Boolean).join(" "); + const expiry = item.expiresAt ? ` (expires ${new Date(item.expiresAt).toLocaleDateString()})` : ""; + lines.push(`- ${qty ? `${qty} ` : ""}${item.rawName}${expiry}`); + } + + return lines.join("\n").trim() + "\n"; +} diff --git a/apps/web/lib/markdown/recipe.ts b/apps/web/lib/markdown/recipe.ts new file mode 100644 index 0000000..d5a29df --- /dev/null +++ b/apps/web/lib/markdown/recipe.ts @@ -0,0 +1,54 @@ +type RecipeMarkdownInput = { + title: string; + description: string | null; + baseServings: number; + prepMins: number | null; + cookMins: number | null; + difficulty: "easy" | "medium" | "hard" | null; + sourceUrl: string | null; + ingredients: Array<{ rawName: string; quantity: string | null; unit: string | null; note: string | null }>; + steps: Array<{ instruction: string; timerSeconds: number | null }>; +}; + +function formatQuantity(quantity: string | null, unit: string | null): string { + return [quantity, unit].filter(Boolean).join(" "); +} + +export function recipeToMarkdown(recipe: RecipeMarkdownInput): string { + const lines: string[] = [`# ${recipe.title}`, ""]; + + if (recipe.description) { + lines.push(recipe.description, ""); + } + + const meta: string[] = [`Servings: ${recipe.baseServings}`]; + if (recipe.prepMins) meta.push(`Prep: ${recipe.prepMins} min`); + if (recipe.cookMins) meta.push(`Cook: ${recipe.cookMins} min`); + if (recipe.difficulty) meta.push(`Difficulty: ${recipe.difficulty}`); + lines.push(meta.join(" · "), ""); + + if (recipe.ingredients.length > 0) { + lines.push("## Ingredients", ""); + for (const ing of recipe.ingredients) { + const qty = formatQuantity(ing.quantity, ing.unit); + const note = ing.note ? ` (${ing.note})` : ""; + lines.push(`- ${qty ? `${qty} ` : ""}${ing.rawName}${note}`); + } + lines.push(""); + } + + if (recipe.steps.length > 0) { + lines.push("## Instructions", ""); + recipe.steps.forEach((step, i) => { + const timer = step.timerSeconds ? ` (${Math.round(step.timerSeconds / 60)} min)` : ""; + lines.push(`${i + 1}. ${step.instruction}${timer}`); + }); + lines.push(""); + } + + if (recipe.sourceUrl) { + lines.push(`Source: ${recipe.sourceUrl}`); + } + + return lines.join("\n").trim() + "\n"; +} diff --git a/apps/web/lib/markdown/shopping-list.ts b/apps/web/lib/markdown/shopping-list.ts new file mode 100644 index 0000000..11c1919 --- /dev/null +++ b/apps/web/lib/markdown/shopping-list.ts @@ -0,0 +1,27 @@ +type ShoppingListMarkdownInput = { + name: string; + items: Array<{ rawName: string; quantity: string | null; unit: string | null; aisle: string | null; checked: boolean }>; +}; + +export function shoppingListToMarkdown(list: ShoppingListMarkdownInput): string { + const lines: string[] = [`# ${list.name}`, ""]; + + const byAisle = new Map(); + for (const item of list.items) { + const aisle = item.aisle ?? "Other"; + const group = byAisle.get(aisle) ?? []; + group.push(item); + byAisle.set(aisle, group); + } + + for (const [aisle, items] of byAisle) { + lines.push(`## ${aisle}`, ""); + for (const item of items) { + const qty = [item.quantity, item.unit].filter(Boolean).join(" "); + lines.push(`- [${item.checked ? "x" : " "}] ${qty ? `${qty} ` : ""}${item.rawName}`); + } + lines.push(""); + } + + return lines.join("\n").trim() + "\n"; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 951d924..6a6d243 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -301,6 +301,11 @@ "save": "Save", "saved": "Saved", "saveFailed": "Failed to save", + "exportMarkdown": "Export as Markdown", + "copyMarkdown": "Copy as Markdown", + "downloadMarkdown": "Download as Markdown", + "copiedToClipboard": "Copied to clipboard", + "copyFailed": "Failed to copy", "print": "Print", "share": "Share", "deleteFailed": "Delete failed", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 6a3f42d..409c394 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -301,6 +301,11 @@ "save": "Enregistrer", "saved": "Enregistré", "saveFailed": "Échec de l'enregistrement", + "exportMarkdown": "Exporter en Markdown", + "copyMarkdown": "Copier en Markdown", + "downloadMarkdown": "Télécharger en Markdown", + "copiedToClipboard": "Copié dans le presse-papiers", + "copyFailed": "Échec de la copie", "print": "Imprimer", "share": "Partager", "deleteFailed": "Échec de la suppression",