Update features and dependencies
This commit is contained in:
@@ -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<string, string> = {
|
||||
vegan: "Vegan",
|
||||
vegetarian: "Vegetarian",
|
||||
glutenFree: "Gluten-free",
|
||||
dairyFree: "Dairy-free",
|
||||
nutFree: "Nut-free",
|
||||
halal: "Halal",
|
||||
kosher: "Kosher",
|
||||
};
|
||||
|
||||
const activeTags = Object.entries(recipe.dietaryTags ?? {})
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => DIETARY_LABELS[k])
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 2em; margin: 0 0 8px; line-height: 1.2; }
|
||||
h2 { font-size: 1.1em; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin: 24px 0 12px; }
|
||||
.meta { display: flex; gap: 24px; font-size: 0.85em; color: #555; margin: 12px 0 16px; flex-wrap: wrap; }
|
||||
.meta span { display: flex; align-items: center; gap: 4px; }
|
||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.tag { border: 1px solid #ccc; border-radius: 4px; padding: 1px 8px; font-size: 0.8em; font-family: system-ui, sans-serif; }
|
||||
.description { color: #444; font-style: italic; margin-bottom: 16px; }
|
||||
ul.ingredients { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
|
||||
ul.ingredients li { padding: 4px 0; border-bottom: 1px dotted #e0e0e0; font-family: system-ui, sans-serif; font-size: 0.9em; }
|
||||
ul.ingredients li .qty { color: #666; min-width: 60px; display: inline-block; }
|
||||
ol.steps { padding-left: 20px; margin: 0; }
|
||||
ol.steps li { padding: 6px 0 6px 4px; border-bottom: 1px dotted #e0e0e0; font-size: 0.95em; }
|
||||
ol.steps li:last-child { border-bottom: none; }
|
||||
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px; font-family: system-ui, sans-serif;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<article>
|
||||
<h1>{recipe.title}</h1>
|
||||
|
||||
{recipe.description && (
|
||||
<p className="description">{recipe.description}</p>
|
||||
)}
|
||||
|
||||
<div className="meta">
|
||||
{recipe.baseServings && <span>Serves {recipe.baseServings}</span>}
|
||||
{recipe.prepMins && <span>Prep {recipe.prepMins} min</span>}
|
||||
{recipe.cookMins && <span>Cook {recipe.cookMins} min</span>}
|
||||
{totalMins > 0 && <span>Total {totalMins} min</span>}
|
||||
{recipe.difficulty && <span>{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}</span>}
|
||||
</div>
|
||||
|
||||
{activeTags.length > 0 && (
|
||||
<div className="tags">
|
||||
{activeTags.map((tag) => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<>
|
||||
<h2>Ingredients</h2>
|
||||
<ul className="ingredients">
|
||||
{recipe.ingredients.map((ing) => (
|
||||
<li key={ing.id}>
|
||||
<span className="qty">
|
||||
{[ing.quantity, ing.unit].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
{ing.rawName}
|
||||
{ing.note && <span style={{ color: "#888" }}> ({ing.note})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipe.steps.length > 0 && (
|
||||
<>
|
||||
<h2>Instructions</h2>
|
||||
<ol className="steps">
|
||||
{recipe.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.instruction}
|
||||
{step.timerSeconds && (
|
||||
<span className="timer">⏱ {Math.floor(step.timerSeconds / 60)} min</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, mealPlans, eq, and } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
|
||||
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
const DAY_LABELS: Record<string, string> = {
|
||||
mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday",
|
||||
fri: "Friday", sat: "Saturday", sun: "Sunday",
|
||||
};
|
||||
const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||||
const MEAL_LABELS: Record<string, string> = {
|
||||
breakfast: "Breakfast", lunch: "Lunch", dinner: "Dinner", snack: "Snack",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default async function MealPlanPrintPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ week?: string }>;
|
||||
}) {
|
||||
const { week } = await searchParams;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const monday = getMonday(week);
|
||||
const weekStart = monday.toISOString().slice(0, 10);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(sunday.getDate() + 6);
|
||||
|
||||
const plan = await db.query.mealPlans.findFirst({
|
||||
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
|
||||
with: { entries: { with: { recipe: true } } },
|
||||
});
|
||||
|
||||
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
||||
|
||||
const entries = plan?.entries ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 { font-size: 1.8em; margin: 0 0 2px; }
|
||||
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
th { text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em; color: #888; padding: 6px 8px; border-bottom: 2px solid #ccc; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid #eee; border-right: 1px solid #eee; vertical-align: top; min-height: 40px; }
|
||||
td:last-child { border-right: none; }
|
||||
.meal-label { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #aaa; display: block; margin-bottom: 2px; }
|
||||
.recipe-title { font-weight: 500; }
|
||||
.servings { font-size: 0.8em; color: #888; }
|
||||
.empty { color: #ccc; font-size: 0.85em; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<h1>Meal Plan</h1>
|
||||
<p className="subtitle">{label}</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "100px" }}>Day</th>
|
||||
{MEAL_ORDER.map((m) => (
|
||||
<th key={m}>{MEAL_LABELS[m]}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DAYS.map((day) => (
|
||||
<tr key={day}>
|
||||
<td style={{ fontWeight: 600, color: "#555" }}>{DAY_LABELS[day]}</td>
|
||||
{MEAL_ORDER.map((mealType) => {
|
||||
const entry = entries.find((e) => e.day === day && e.mealType === mealType);
|
||||
return (
|
||||
<td key={mealType}>
|
||||
{entry ? (
|
||||
<>
|
||||
<span className="recipe-title">{entry.recipe?.title ?? entry.note ?? "—"}</span>
|
||||
{entry.servings && (
|
||||
<span className="servings"> · {entry.servings} srv</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, pantryItems, eq, asc } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
|
||||
export default async function PantryPrintPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const items = await db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, session.user.id),
|
||||
orderBy: asc(pantryItems.rawName),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 1.8em; margin: 0 0 4px; }
|
||||
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em; color: #888; padding: 4px 8px 4px 0; border-bottom: 1px solid #ccc; }
|
||||
td { padding: 6px 8px 6px 0; border-bottom: 1px dotted #eee; font-size: 0.9em; vertical-align: top; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.expiry-soon { color: #b45309; }
|
||||
.expiry-expired { color: #dc2626; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<h1>Pantry</h1>
|
||||
<p className="subtitle">{items.length} item{items.length !== 1 ? "s" : ""}</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p style={{ color: "#888" }}>No items in pantry.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Quantity</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const exp = item.expiresAt ? new Date(item.expiresAt) : null;
|
||||
const daysLeft = exp ? Math.ceil((exp.getTime() - now.getTime()) / 86400000) : null;
|
||||
const expiryClass = daysLeft === null ? "" : daysLeft < 0 ? "expiry-expired" : daysLeft <= 3 ? "expiry-soon" : "";
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>{item.rawName}</td>
|
||||
<td>{[item.quantity, item.unit].filter(Boolean).join(" ") || "—"}</td>
|
||||
<td className={expiryClass}>
|
||||
{exp
|
||||
? daysLeft !== null && daysLeft < 0
|
||||
? `Expired ${Math.abs(daysLeft)}d ago`
|
||||
: exp.toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric" })
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, shoppingLists, eq, and } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function ShoppingListPrintPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const list = await db.query.shoppingLists.findFirst({
|
||||
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)),
|
||||
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
|
||||
});
|
||||
|
||||
if (!list) notFound();
|
||||
|
||||
const byAisle = list.items.reduce<Record<string, typeof list.items>>((acc, item) => {
|
||||
const key = item.aisle ?? "Other";
|
||||
(acc[key] ??= []).push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const aisles = Object.keys(byAisle).sort((a, b) =>
|
||||
a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 1.8em; margin: 0 0 4px; }
|
||||
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
|
||||
h2 { font-size: 0.9em; text-transform: uppercase; letter-spacing: 0.08em; color: #888; border-bottom: 1px solid #e0e0e0; padding-bottom: 4px; margin: 20px 0 8px; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
li { display: flex; align-items: baseline; gap: 8px; padding: 5px 0; border-bottom: 1px dotted #eee; font-size: 0.95em; }
|
||||
li:last-child { border-bottom: none; }
|
||||
.check { width: 16px; height: 16px; border: 1.5px solid #999; border-radius: 3px; flex-shrink: 0; display: inline-block; }
|
||||
.checked .check { background: #18181b; border-color: #18181b; }
|
||||
.checked span { text-decoration: line-through; color: #999; }
|
||||
.qty { color: #666; min-width: 70px; font-variant-numeric: tabular-nums; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<h1>{list.name}</h1>
|
||||
<p className="subtitle">
|
||||
{list.items.filter(i => i.checked).length}/{list.items.length} items checked
|
||||
{list.generatedAt ? " · From meal plan" : ""}
|
||||
</p>
|
||||
|
||||
{aisles.map((aisle) => (
|
||||
<section key={aisle}>
|
||||
{aisles.length > 1 && <h2>{aisle}</h2>}
|
||||
<ul>
|
||||
{byAisle[aisle]!.map((item) => (
|
||||
<li key={item.id} className={item.checked ? "checked" : ""}>
|
||||
<span className="check" />
|
||||
<span className="qty">
|
||||
{[item.quantity, item.unit].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
<span>{item.rawName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user