From 580d5bbf2f86361c0f52a49e1adc051d786ce315 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 10 Jul 2026 15:37:23 +0200 Subject: [PATCH] feat: favorite button on the My Recipes grid; fix mobile overflow in bulk-action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a heart toggle to all three recipe-grid views (grid card overlay, list row, compact row), hidden while in multi-select mode. Detail page already had it; this was the other place it made sense. - recipes/page.tsx now fetches which of the current page's recipes are already favorited and passes it through. - FavoriteButton gained an optional iconClassName so the unfavorited heart reads on a photo overlay (was invisible in light mode against the dark backdrop — filled/favorited state was already fine since it's opaque red). - Fixed the floating bulk-select action bar overflowing both edges of the viewport on mobile (screenshotted: centered with no max-width, wider than the screen, clipping "Supprimer" on one side and the selected-count label on the other). Now caps to viewport width, scrolls horizontally as a fallback, and drops button text labels below sm (icon + aria-label + tooltip only), matching the icon-row convention used elsewhere. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(app)/recipes/page.tsx | 16 +++++-- apps/web/components/recipe/recipes-grid.tsx | 48 ++++++++++++++----- .../web/components/social/favorite-button.tsx | 5 +- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index 215bd45..fec4ec2 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -2,8 +2,8 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; import Link from "next/link"; import { auth } from "@/lib/auth/server"; -import { db, recipes, sql, count } from "@epicure/db"; -import { eq, desc, asc, and, ilike, or } from "@epicure/db"; +import { db, recipes, favorites, sql, count } from "@epicure/db"; +import { eq, desc, asc, and, ilike, or, inArray } 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"; @@ -76,6 +76,16 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear const total = totalRow[0]?.count ?? 0; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const recipeIds = userRecipes.map((r) => r.id); + const favoritedRows = recipeIds.length > 0 + ? await db + .select({ recipeId: favorites.recipeId }) + .from(favorites) + .where(and(eq(favorites.userId, session.user.id), inArray(favorites.recipeId, recipeIds))) + : []; + const favoritedIds = new Set(favoritedRows.map((r) => r.recipeId)); + const recipesWithFavorites = userRecipes.map((r) => ({ ...r, isFavorited: favoritedIds.has(r.id) })); + const pageHref = (p: number) => { const params = new URLSearchParams(); if (query) params.set("q", query); @@ -105,7 +115,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear initialTag={tagFilter ?? ""} /> - + {totalPages > 1 && (
diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 0478176..f4317ee 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -23,6 +23,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { FavoriteButton } from "@/components/social/favorite-button"; import { toast } from "sonner"; import { getPublicUrl } from "@/lib/storage"; import { useLocale } from "@/lib/i18n/provider"; @@ -43,8 +44,14 @@ type Recipe = { tags: string[]; updatedAt: Date; photos?: Array<{ storageKey: string; isCover: boolean }>; + isFavorited?: boolean; }; +/** Stops the click from bubbling to the card's own Link/select handler. */ +function StopPropagation({ children }: { children: React.ReactNode }) { + return
e.stopPropagation()}>{children}
; +} + type ViewMode = "grid" | "list" | "compact"; const VIEW_STORAGE_KEY = "epicure-recipes-view"; @@ -103,6 +110,13 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: )} > + {!selectMode && ( +
+ + + +
+ )}

{recipe.title} @@ -155,7 +169,14 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b

{recipe.title}

- +
+ {!selectMode && ( + + + + )} + +
{recipe.description &&

{recipe.description}

}
@@ -198,6 +219,11 @@ function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected {recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })} + {!selectMode && ( + + + + )}
); @@ -403,14 +429,14 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {/* Floating bulk action bar */} {selectMode && selected.size > 0 && ( -
-
- {t("selectedCount", { count: selected.size })} -
+
+
+ {selected.size} +
- + - {t("visibilityMenuLabel")} + {t("visibilityMenuLabel")} { void bulkSetVisibility("public"); }}> @@ -424,13 +450,13 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) - -
diff --git a/apps/web/components/social/favorite-button.tsx b/apps/web/components/social/favorite-button.tsx index a3a74ae..b898e8f 100644 --- a/apps/web/components/social/favorite-button.tsx +++ b/apps/web/components/social/favorite-button.tsx @@ -12,11 +12,14 @@ export function FavoriteButton({ recipeId, initialFavorited = false, onToggle, + iconClassName, }: { recipeId: string; initialFavorited?: boolean; /** Called after a successful toggle, e.g. to remove the recipe from a "Favorites" list view. */ onToggle?: (favorited: boolean) => void; + /** Extra classes for the heart icon when unfavorited — e.g. forcing a light color when the button overlays a photo. */ + iconClassName?: string; }) { const tCommon = useTranslations("common"); const tSocial = useTranslations("social"); @@ -52,7 +55,7 @@ export function FavoriteButton({ disabled={loading} aria-label={favorited ? tSocial("favoriteRemove") : tSocial("favoriteAdd")} > - + } /> {favorited ? "Saved" : "Save"}