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,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>
);
}