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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChefHat, Clock } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DIFFICULTY_COLORS: Record<string, string> = {
|
||||
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 (
|
||||
<Link
|
||||
href={`/recipes/${recipe.id}`}
|
||||
className={cn(
|
||||
"group relative flex flex-col overflow-hidden rounded-xl border bg-card shadow-sm transition-shadow hover:shadow-md",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
|
||||
{recipe.coverPhotoKey ? (
|
||||
<Image
|
||||
src={getPublicUrl(recipe.coverPhotoKey)}
|
||||
alt={recipe.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-3xl text-muted-foreground/40">
|
||||
<ChefHat className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-1 top-1" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<FavoriteButton
|
||||
recipeId={recipe.id}
|
||||
initialFavorited
|
||||
onToggle={(favorited) => { if (!favorited) onUnfavorited?.(recipe.id); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="line-clamp-2 flex-1 text-sm font-semibold group-hover:text-primary">
|
||||
{recipe.title}
|
||||
</h3>
|
||||
{recipe.difficulty && (
|
||||
<Badge variant="secondary" className={cn("shrink-0 capitalize text-xs", DIFFICULTY_COLORS[recipe.difficulty])}>
|
||||
{difficultyLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{totalMins > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{t("total", { mins: totalMins })}
|
||||
</span>
|
||||
)}
|
||||
{recipe.authorName && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<ChefHat className="h-3.5 w-3.5 shrink-0" />
|
||||
{recipe.authorName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
|
||||
<Heart className="h-12 w-12 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground text-sm text-center max-w-xs">{emptyLabel}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start">
|
||||
{recipes.map((recipe) => (
|
||||
<FavoriteRecipeCard key={recipe.id} recipe={recipe} onUnfavorited={handleUnfavorited} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user