Update features and dependencies

This commit is contained in:
Arnaud
2026-07-01 11:10:37 +02:00
parent 9d9dfb46c6
commit 8b57a3fd87
107 changed files with 14654 additions and 458 deletions
@@ -35,6 +35,7 @@ export default async function EditRecipePage({ params }: Params) {
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
cookMins: recipe.cookMins,
tags: recipe.tags ?? [],
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
ingredients: recipe.ingredients.map((ing) => ({
id: ing.id,
+54 -14
View File
@@ -13,18 +13,21 @@ import { PrintButton } from "@/components/recipe/print-button";
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
import { auth } from "@/lib/auth/server";
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
import { and, eq, count } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ServingScaler } from "@/components/recipe/serving-scaler";
import { FavoriteButton } from "@/components/social/favorite-button";
import { RatingStars } from "@/components/social/rating-stars";
import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage";
import { cn } from "@/lib/utils";
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
type Params = { params: Promise<{ id: string }> };
@@ -86,14 +89,30 @@ export default async function RecipePage({ params }: Params) {
{/* Header */}
<div className="space-y-4">
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
<TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{recipe.steps.length > 0 && (
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Play className="h-4 w-4" />
</Link>
} />
<TooltipContent>Cook</TooltipContent>
</Tooltip>
)}
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
<MealPairingButton recipeId={id} />
<DrinkPairingButton recipeId={id} />
{recipe.visibility === "public" && (
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
<ExternalLink className="h-4 w-4" />
</Link>
<Tooltip>
<TooltipTrigger render={
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<ExternalLink className="h-4 w-4" />
</Link>
} />
<TooltipContent>View publicly</TooltipContent>
</Tooltip>
)}
{recipe.ingredients.length > 0 && (
<AddToShoppingListButton
@@ -107,7 +126,9 @@ export default async function RecipePage({ params }: Params) {
}))}
/>
)}
<TranslateButton recipeId={id} />
{(!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && (
<TranslateButton recipeId={id} />
)}
{recipe.ingredients.length > 0 && (
<AdaptRecipeButton
recipeId={id}
@@ -133,20 +154,19 @@ export default async function RecipePage({ params }: Params) {
order: s.order,
}))}
/>
{recipe.steps.length > 0 && (
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "default", size: "sm" }))}>
<Play className="h-4 w-4" />
Cook
</Link>
)}
<PrintButton recipeId={id} />
<VersionHistoryButton recipeId={id} />
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Pencil className="h-4 w-4" />
Edit
</Link>
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Pencil className="h-4 w-4" />
</Link>
} />
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<DeleteRecipeButton recipeId={id} />
</div>
</TooltipProvider>
{avgScore !== null && (
<div className="flex items-center gap-2">
@@ -210,6 +230,24 @@ export default async function RecipePage({ params }: Params) {
<Separator />
{/* Empty state */}
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-muted-foreground">No ingredients or steps yet.</p>
<div className="flex items-center gap-2">
<GenerateContentButton
recipeId={id}
title={recipe.title}
description={recipe.description}
/>
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Pencil className="h-4 w-4" />
Edit manually
</Link>
</div>
</div>
)}
{/* Serving scaler + ingredients */}
{recipe.ingredients.length > 0 && (
<div className="space-y-4">
@@ -285,6 +323,8 @@ export default async function RecipePage({ params }: Params) {
<CommentsSection recipeId={id} currentUserId={session.user.id} />
</>
)}
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
</div>
);
}
@@ -1,142 +0,0 @@
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>
</>
);
}
+49 -22
View File
@@ -1,48 +1,75 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, recipeIngredients } from "@epicure/db";
import { eq, desc, and, ilike, or, sql } from "@epicure/db";
import { db, recipes, sql } from "@epicure/db";
import { eq, desc, asc, and, ilike, or } from "@epicure/db";
import { RecipesHeader } from "@/components/recipe/recipes-header";
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
import { RecipesGrid } from "@/components/recipe/recipes-grid";
export const metadata: Metadata = { title: "Recipes" };
type SearchParams = Promise<{ q?: string }>;
type SearchParams = Promise<{
q?: string;
sort?: string;
visibility?: string;
difficulty?: string;
tag?: string;
}>;
const SORT_MAP = {
updated_desc: desc(recipes.updatedAt),
updated_asc: asc(recipes.updatedAt),
created_desc: desc(recipes.createdAt),
created_asc: asc(recipes.createdAt),
title_asc: asc(recipes.title),
title_desc: desc(recipes.title),
} as const;
type SortKey = keyof typeof SORT_MAP;
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { q } = await searchParams;
const query = q?.trim() ?? "";
const { q, sort, visibility, difficulty, tag } = await searchParams;
const query = (q ?? "").trim().slice(0, 200);
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
const tagFilter = tag?.trim().slice(0, 50) || undefined;
const whereClause = query
? and(
eq(recipes.authorId, session.user.id),
or(
ilike(recipes.title, `%${query}%`),
ilike(recipes.description, `%${query}%`)
)
)
: eq(recipes.authorId, session.user.id);
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
? (visibility as "private" | "unlisted" | "public")
: undefined;
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
? (difficulty as "easy" | "medium" | "hard")
: undefined;
const userRecipes = await db.query.recipes.findMany({
where: whereClause,
orderBy: desc(recipes.updatedAt),
where: and(
eq(recipes.authorId, session.user.id),
query
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
: undefined,
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
),
orderBy: SORT_MAP[sortKey],
with: { photos: true },
});
const totalCount = query ? undefined : userRecipes.length;
return (
<div className="space-y-6">
<RecipesHeader count={totalCount ?? userRecipes.length} initialQuery={query} />
<RecipesHeader
count={userRecipes.length}
initialQuery={query}
initialSort={sortKey}
initialVisibility={visibilityFilter ?? ""}
initialDifficulty={difficultyFilter ?? ""}
initialTag={tagFilter ?? ""}
/>
<RecipesEmptyState query={query} count={userRecipes.length} />
<RecipesGrid recipes={userRecipes} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} />
</div>
);
}