From a5661ef8822996bcd7e4ba36370b5c681ebd7566 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 10 Jul 2026 13:46:15 +0200 Subject: [PATCH] feat: dedicated favorites page for recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favoriting already worked (heart toggle on the recipe detail page, DB table, API route) but there was nowhere to see what you'd favorited — the actual missing piece behind "add a favorites feature". - New /recipes/favorites page: paginated grid of favorited recipes (cover photo, difficulty, time, author), sorted by most-recently favorited. A previously-favorited recipe stays visible even if the author later goes private, matching the existing private-accounts scope decision (existing access isn't revoked, only new discovery is affected). - Unfavoriting from that page removes the card immediately — added an optional onToggle callback to FavoriteButton for this. - Added a lightweight "My Recipes / Favorites" tab pair at the top of /recipes linking between the two, rather than a new top-level nav item (nav is already at 8 entries). Deliberately out of scope: favorite toggles on the main recipe grid, feed cards, or search/explore results — favoriting your own recipes is odd UX, and wiring session-aware favorited state into the public (unauthenticated) search route is a separate, bigger change. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(app)/recipes/favorites/page.tsx | 121 ++++++++++++++++++ apps/web/app/(app)/recipes/page.tsx | 8 ++ .../recipe/favorite-recipe-card.tsx | 104 +++++++++++++++ apps/web/components/recipe/favorites-grid.tsx | 36 ++++++ .../web/components/social/favorite-button.tsx | 4 + apps/web/messages/en.json | 7 +- apps/web/messages/fr.json | 7 +- 7 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(app)/recipes/favorites/page.tsx create mode 100644 apps/web/components/recipe/favorite-recipe-card.tsx create mode 100644 apps/web/components/recipe/favorites-grid.tsx diff --git a/apps/web/app/(app)/recipes/favorites/page.tsx b/apps/web/app/(app)/recipes/favorites/page.tsx new file mode 100644 index 0000000..21ec2ee --- /dev/null +++ b/apps/web/app/(app)/recipes/favorites/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db, favorites, recipes, recipePhotos, users, eq, and, or, ne, desc, inArray, count } from "@epicure/db"; +import { FavoritesGrid } from "@/components/recipe/favorites-grid"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; + +export const metadata: Metadata = {}; + +const PAGE_SIZE = 24; + +export default async function FavoriteRecipesPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + const m = getMessages((session.user as { locale?: string }).locale); + + const { page: pageParam } = await searchParams; + const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1); + const offset = (page - 1) * PAGE_SIZE; + + // A previously-favorited recipe stays visible to the person who favorited it even if + // the author later makes their account/recipe private — same "existing access isn't + // revoked, only new discovery is" rule as the private-accounts feature. + const where = and( + eq(favorites.userId, session.user.id), + or(ne(recipes.visibility, "private"), eq(recipes.authorId, session.user.id)) + ); + + const [rows, totalRow] = await Promise.all([ + db + .select({ + id: recipes.id, + title: recipes.title, + difficulty: recipes.difficulty, + prepMins: recipes.prepMins, + cookMins: recipes.cookMins, + authorId: recipes.authorId, + authorName: users.name, + }) + .from(favorites) + .innerJoin(recipes, eq(favorites.recipeId, recipes.id)) + .innerJoin(users, eq(recipes.authorId, users.id)) + .where(where) + .orderBy(desc(favorites.createdAt)) + .limit(PAGE_SIZE) + .offset(offset), + db + .select({ total: count() }) + .from(favorites) + .innerJoin(recipes, eq(favorites.recipeId, recipes.id)) + .where(where), + ]); + + const recipeIds = rows.map((r) => r.id); + const photos = recipeIds.length > 0 + ? await db + .select({ recipeId: recipePhotos.recipeId, storageKey: recipePhotos.storageKey, isCover: recipePhotos.isCover }) + .from(recipePhotos) + .where(inArray(recipePhotos.recipeId, recipeIds)) + : []; + const coverByRecipe = new Map(); + for (const p of photos) { + if (!coverByRecipe.has(p.recipeId) || p.isCover) coverByRecipe.set(p.recipeId, p.storageKey); + } + + const total = totalRow[0]?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const favoriteRecipes = rows.map((r) => ({ + id: r.id, + title: r.title, + difficulty: r.difficulty, + prepMins: r.prepMins, + cookMins: r.cookMins, + authorName: r.authorId === session.user.id ? null : r.authorName, + coverPhotoKey: coverByRecipe.get(r.id) ?? null, + })); + + return ( +
+
+ + {m.recipes.tabMine} + + {m.recipes.tabFavorites} +
+ +
+

{m.recipes.tabFavorites}

+

+ {formatMessage(total === 1 ? m.recipes.favoritesCount : m.recipes.favoritesCountPlural, { count: total })} +

+
+ + + + {totalPages > 1 && ( +
+ {page > 1 && ( + + Previous + + )} + + Page {page} of {totalPages} + + {page < totalPages && ( + + Next + + )} +
+ )} +
+ ); +} diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index 4241f6e..215bd45 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -7,6 +7,7 @@ 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"; +import { getMessages } from "@/lib/i18n/server"; export const metadata: Metadata = {}; @@ -35,6 +36,7 @@ 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 m = getMessages((session.user as { locale?: string }).locale); const { q, sort, visibility, difficulty, tag, page: pageParam } = await searchParams; const query = (q ?? "").trim().slice(0, 200); @@ -88,6 +90,12 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear return (
+
+ {m.recipes.tabMine} + + {m.recipes.tabFavorites} + +
= { + easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", +}; + +export type FavoriteRecipe = { + id: string; + title: string; + difficulty: string | null; + prepMins: number | null; + cookMins: number | null; + authorName: string | null; + coverPhotoKey: string | null; +}; + +/** Card for the "Favorites" grid — mirrors SearchResultCard's layout with a cover + * photo and an unfavorite toggle, since this view is specifically for managing + * (and removing from) your saved recipes, not just browsing them. */ +export function FavoriteRecipeCard({ + recipe, + className, + onUnfavorited, +}: { + recipe: FavoriteRecipe; + className?: string; + onUnfavorited?: (recipeId: string) => void; +}) { + const t = useTranslations("recipe"); + const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); + const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard" + ? t(`difficulty.${recipe.difficulty}`) + : recipe.difficulty; + + return ( + +
+ {recipe.coverPhotoKey ? ( + {recipe.title} + ) : ( +
+ +
+ )} +
{ e.preventDefault(); e.stopPropagation(); }}> + { if (!favorited) onUnfavorited?.(recipe.id); }} + /> +
+
+
+
+

+ {recipe.title} +

+ {recipe.difficulty && ( + + {difficultyLabel} + + )} +
+
+ {totalMins > 0 && ( + + + {t("total", { mins: totalMins })} + + )} + {recipe.authorName && ( + + + {recipe.authorName} + + )} +
+
+ + ); +} diff --git a/apps/web/components/recipe/favorites-grid.tsx b/apps/web/components/recipe/favorites-grid.tsx new file mode 100644 index 0000000..287b5a6 --- /dev/null +++ b/apps/web/components/recipe/favorites-grid.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { Heart } from "lucide-react"; +import { FavoriteRecipeCard, type FavoriteRecipe } from "@/components/recipe/favorite-recipe-card"; + +export function FavoritesGrid({ + initialRecipes, + emptyLabel, +}: { + initialRecipes: FavoriteRecipe[]; + emptyLabel: string; +}) { + const [recipes, setRecipes] = useState(initialRecipes); + + function handleUnfavorited(recipeId: string) { + setRecipes((prev) => prev.filter((r) => r.id !== recipeId)); + } + + if (recipes.length === 0) { + return ( +
+ +

{emptyLabel}

+
+ ); + } + + return ( +
+ {recipes.map((recipe) => ( + + ))} +
+ ); +} diff --git a/apps/web/components/social/favorite-button.tsx b/apps/web/components/social/favorite-button.tsx index 6b888e5..a3a74ae 100644 --- a/apps/web/components/social/favorite-button.tsx +++ b/apps/web/components/social/favorite-button.tsx @@ -11,9 +11,12 @@ import { cn } from "@/lib/utils"; export function FavoriteButton({ recipeId, initialFavorited = false, + onToggle, }: { recipeId: string; initialFavorited?: boolean; + /** Called after a successful toggle, e.g. to remove the recipe from a "Favorites" list view. */ + onToggle?: (favorited: boolean) => void; }) { const tCommon = useTranslations("common"); const tSocial = useTranslations("social"); @@ -29,6 +32,7 @@ export function FavoriteButton({ method: next ? "POST" : "DELETE", }); if (!res.ok) throw new Error(); + onToggle?.(next); } catch { setFavorited(!next); toast.error(tCommon("updateFailed")); diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8d0c6e8..46b8608 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -566,7 +566,12 @@ "visibilityLabel": "Visibility", "difficultyLabel": "Difficulty", "tagLabel": "Tag", - "clearFilters": "Clear filters" + "clearFilters": "Clear filters", + "tabMine": "My Recipes", + "tabFavorites": "Favorites", + "favoritesCount": "{count} favorited recipe", + "favoritesCountPlural": "{count} favorited recipes", + "favoritesEmpty": "No favorited recipes yet — tap the heart on any recipe to save it here." }, "recipeNotes": { "title": "Your private notes", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index bb83a86..9f768d3 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -557,7 +557,12 @@ "visibilityLabel": "Visibilité", "difficultyLabel": "Difficulté", "tagLabel": "Tag", - "clearFilters": "Effacer les filtres" + "clearFilters": "Effacer les filtres", + "tabMine": "Mes recettes", + "tabFavorites": "Favoris", + "favoritesCount": "{count} recette favorite", + "favoritesCountPlural": "{count} recettes favorites", + "favoritesEmpty": "Aucune recette favorite pour l'instant — touchez le cœur sur une recette pour l'enregistrer ici." }, "recipeNotes": { "title": "Vos notes personnelles",