feat: favorite button on the My Recipes grid; fix mobile overflow in bulk-action bar
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, sql, count } from "@epicure/db";
|
import { db, recipes, favorites, sql, count } from "@epicure/db";
|
||||||
import { eq, desc, asc, and, ilike, or } from "@epicure/db";
|
import { eq, desc, asc, and, ilike, or, inArray } from "@epicure/db";
|
||||||
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
||||||
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
||||||
import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
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 total = totalRow[0]?.count ?? 0;
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
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 pageHref = (p: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query) params.set("q", query);
|
if (query) params.set("q", query);
|
||||||
@@ -105,7 +115,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
initialTag={tagFilter ?? ""}
|
initialTag={tagFilter ?? ""}
|
||||||
/>
|
/>
|
||||||
<RecipesEmptyState query={query} count={total} />
|
<RecipesEmptyState query={query} count={total} />
|
||||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${page}`} recipes={userRecipes} />
|
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${page}`} recipes={recipesWithFavorites} />
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 pt-2">
|
<div className="flex items-center justify-center gap-2 pt-2">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
import { useLocale } from "@/lib/i18n/provider";
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
@@ -43,8 +44,14 @@ type Recipe = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
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 <div onClick={(e) => e.stopPropagation()}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
type ViewMode = "grid" | "list" | "compact";
|
type ViewMode = "grid" | "list" | "compact";
|
||||||
const VIEW_STORAGE_KEY = "epicure-recipes-view";
|
const VIEW_STORAGE_KEY = "epicure-recipes-view";
|
||||||
|
|
||||||
@@ -103,6 +110,13 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RecipeThumb recipe={recipe} selectMode={selectMode} selected={selected} className="aspect-video" />
|
<RecipeThumb recipe={recipe} selectMode={selectMode} selected={selected} className="aspect-video" />
|
||||||
|
{!selectMode && (
|
||||||
|
<div className="absolute right-1.5 top-1.5 rounded-full bg-black/30 backdrop-blur-sm">
|
||||||
|
<StopPropagation>
|
||||||
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} iconClassName="text-white" />
|
||||||
|
</StopPropagation>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col flex-1 p-3 gap-1">
|
<div className="flex flex-col flex-1 p-3 gap-1">
|
||||||
<h3 className={cn("font-semibold leading-tight line-clamp-2 text-sm transition-colors", !selectMode && "group-hover:text-primary")}>
|
<h3 className={cn("font-semibold leading-tight line-clamp-2 text-sm transition-colors", !selectMode && "group-hover:text-primary")}>
|
||||||
{recipe.title}
|
{recipe.title}
|
||||||
@@ -155,7 +169,14 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b
|
|||||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h3 className={cn("font-semibold leading-tight line-clamp-1 text-sm", !selectMode && "group-hover:text-primary")}>{recipe.title}</h3>
|
<h3 className={cn("font-semibold leading-tight line-clamp-1 text-sm", !selectMode && "group-hover:text-primary")}>{recipe.title}</h3>
|
||||||
<VisibilityIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground mt-0.5" />
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{!selectMode && (
|
||||||
|
<StopPropagation>
|
||||||
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
||||||
|
</StopPropagation>
|
||||||
|
)}
|
||||||
|
<VisibilityIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>}
|
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>}
|
||||||
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap">
|
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap">
|
||||||
@@ -198,6 +219,11 @@ function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected
|
|||||||
<span className="hidden md:inline text-xs text-muted-foreground shrink-0 w-16 text-right">
|
<span className="hidden md:inline text-xs text-muted-foreground shrink-0 w-16 text-right">
|
||||||
{recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })}
|
{recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
|
{!selectMode && (
|
||||||
|
<StopPropagation>
|
||||||
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
||||||
|
</StopPropagation>
|
||||||
|
)}
|
||||||
<VisibilityIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<VisibilityIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -403,14 +429,14 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
|||||||
|
|
||||||
{/* Floating bulk action bar */}
|
{/* Floating bulk action bar */}
|
||||||
{selectMode && selected.size > 0 && (
|
{selectMode && selected.size > 0 && (
|
||||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 duration-200">
|
<div className="fixed bottom-4 sm:bottom-8 left-1/2 -translate-x-1/2 z-50 w-[calc(100vw-2rem)] max-w-md sm:w-auto animate-in slide-in-from-bottom-4 duration-200">
|
||||||
<div className="flex items-center gap-2 bg-popover border shadow-2xl rounded-2xl px-5 py-3">
|
<div className="flex items-center gap-1 sm:gap-2 bg-popover border shadow-2xl rounded-2xl px-2 sm:px-5 py-2 sm:py-3 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<span className="text-sm font-semibold tabular-nums mr-1">{t("selectedCount", { count: selected.size })}</span>
|
<span className="text-sm font-semibold tabular-nums mr-1 shrink-0">{selected.size}</span>
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1 shrink-0" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger disabled={busy} className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}>
|
<DropdownMenuTrigger disabled={busy} className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5 shrink-0"} aria-label={t("visibilityMenuLabel")}>
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
{t("visibilityMenuLabel")}
|
<span className="hidden sm:inline">{t("visibilityMenuLabel")}</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="center" side="top">
|
<DropdownMenuContent align="center" side="top">
|
||||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
|
||||||
@@ -424,13 +450,13 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setAddToCollectionOpen(true)} disabled={busy} className="gap-1.5">
|
<Button variant="ghost" size="sm" onClick={() => setAddToCollectionOpen(true)} disabled={busy} className="gap-1.5 shrink-0" aria-label={t("addToCollection")}>
|
||||||
<FolderPlus className="h-4 w-4" />
|
<FolderPlus className="h-4 w-4" />
|
||||||
{t("addToCollection")}
|
<span className="hidden sm:inline">{t("addToCollection")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" onClick={() => setDeleteConfirmOpen(true)} disabled={busy} className="gap-1.5">
|
<Button variant="destructive" size="sm" onClick={() => setDeleteConfirmOpen(true)} disabled={busy} className="gap-1.5 shrink-0" aria-label={tCommon("delete")}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
{tCommon("delete")}
|
<span className="hidden sm:inline">{tCommon("delete")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ export function FavoriteButton({
|
|||||||
recipeId,
|
recipeId,
|
||||||
initialFavorited = false,
|
initialFavorited = false,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
iconClassName,
|
||||||
}: {
|
}: {
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
initialFavorited?: boolean;
|
initialFavorited?: boolean;
|
||||||
/** Called after a successful toggle, e.g. to remove the recipe from a "Favorites" list view. */
|
/** Called after a successful toggle, e.g. to remove the recipe from a "Favorites" list view. */
|
||||||
onToggle?: (favorited: boolean) => void;
|
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 tCommon = useTranslations("common");
|
||||||
const tSocial = useTranslations("social");
|
const tSocial = useTranslations("social");
|
||||||
@@ -52,7 +55,7 @@ export function FavoriteButton({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
aria-label={favorited ? tSocial("favoriteRemove") : tSocial("favoriteAdd")}
|
aria-label={favorited ? tSocial("favoriteRemove") : tSocial("favoriteAdd")}
|
||||||
>
|
>
|
||||||
<Heart className={cn("h-4 w-4", favorited && "fill-red-500 text-red-500")} />
|
<Heart className={cn("h-4 w-4", favorited ? "fill-red-500 text-red-500" : iconClassName)} />
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
<TooltipContent>{favorited ? "Saved" : "Save"}</TooltipContent>
|
<TooltipContent>{favorited ? "Saved" : "Save"}</TooltipContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user