feat: dedicated favorites page for recipes

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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-10 13:46:15 +02:00
parent 1a40eccad5
commit a5661ef882
7 changed files with 285 additions and 2 deletions
@@ -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<string, string>();
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 (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm">
<Link href="/recipes" className="rounded-full px-3 py-1 text-muted-foreground hover:bg-accent">
{m.recipes.tabMine}
</Link>
<span className="rounded-full bg-accent px-3 py-1 font-medium">{m.recipes.tabFavorites}</span>
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{m.recipes.tabFavorites}</h1>
<p className="text-muted-foreground mt-1">
{formatMessage(total === 1 ? m.recipes.favoritesCount : m.recipes.favoritesCountPlural, { count: total })}
</p>
</div>
<FavoritesGrid initialRecipes={favoriteRecipes} emptyLabel={m.recipes.favoritesEmpty} />
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
{page > 1 && (
<Link href={`/recipes/favorites?page=${page - 1}`} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Previous
</Link>
)}
<span className="text-sm text-muted-foreground px-2">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link href={`/recipes/favorites?page=${page + 1}`} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Next
</Link>
)}
</div>
)}
</div>
);
}
+8
View File
@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm">
<span className="rounded-full bg-accent px-3 py-1 font-medium">{m.recipes.tabMine}</span>
<Link href="/recipes/favorites" className="rounded-full px-3 py-1 text-muted-foreground hover:bg-accent">
{m.recipes.tabFavorites}
</Link>
</div>
<RecipesHeader
count={total}
initialQuery={query}