Files
Epicure/apps/web/components/recipe/recipe-grid-card.tsx
T
Arnaud 955c4bd9dd fix: varied gradient+icon placeholders for recipes without a cover photo (v0.50.2)
Replaces the single flat emoji-on-muted-bg fallback (same 🍽️/🍹 everywhere)
with a deterministic gradient + food/drink icon per recipe id — pulled from
a small curated palette/icon pool via a string hash, so it's stable across
re-renders but visually varied across a grid of photo-less recipes.

New shared lib/recipe-placeholder.ts + components/recipe/recipe-cover-
placeholder.tsx, applied everywhere the old inline emoji fallback lived:
recipe-grid-card.tsx, recipe-card.tsx, recipes-grid.tsx, and the public
profile page's recipe grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 09:47:22 +02:00

142 lines
6.1 KiB
TypeScript

"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Globe, Lock, Link2, ExternalLink, ChefHat, Clock, Users, Utensils, UserCheck } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { FavoriteButton } from "@/components/social/favorite-button";
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
import { getPublicUrl } from "@/lib/storage";
import { cn, stripMarkdown } from "@/lib/utils";
export type GridCardRecipe = {
id: string;
title: string;
description: string | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
difficulty: "easy" | "medium" | "hard" | null;
visibility: "private" | "unlisted" | "public" | "followers";
tags: string[];
photos?: Array<{ storageKey: string; isCover: boolean }>;
isFavorited?: boolean;
isBatchCook?: boolean;
dishCount?: number;
sourceUrl?: string | null;
recipeType?: "dish" | "drink";
authorName?: string | null;
};
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe, followers: UserCheck };
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
/** Shared cover-photo/emoji-fallback thumbnail used by the recipe grid card. */
export function RecipeThumb({ recipe, className }: { recipe: GridCardRecipe; className?: string }) {
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
return (
<div className={cn("relative overflow-hidden bg-muted shrink-0", className)}>
{cover ? (
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<RecipeCoverPlaceholder recipe={recipe} />
)}
</div>
);
}
/** The recipe grid card shown on Recipes, Explore, and the feed tabs — one visual identity everywhere a recipe appears in a grid. */
export function RecipeGridCard({ recipe }: { recipe: GridCardRecipe }) {
const t = useTranslations("recipe");
const tBatch = useTranslations("batchCooking");
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
<div className="group relative overflow-hidden rounded-xl border bg-card flex flex-col h-full transition-all duration-200 hover:shadow-md hover:border-muted-foreground/30">
<RecipeThumb recipe={recipe} className="aspect-video" />
<div className="flex flex-col flex-1 p-3 gap-1">
{recipe.authorName && (
<p className="text-xs text-muted-foreground truncate">{recipe.authorName}</p>
)}
<h3 className="font-semibold leading-tight line-clamp-2 text-sm transition-colors group-hover:text-primary">
{recipe.title}
</h3>
{recipe.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
</p>
)}
{recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{recipe.tags.slice(0, 3).map((tag) => (
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-muted text-muted-foreground">
{tag}
</span>
))}
{recipe.tags.length > 3 && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground">+{recipe.tags.length - 3}</span>
)}
</div>
)}
</div>
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2.5">
{recipe.isBatchCook && recipe.dishCount ? (
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
) : (
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
)}
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
{recipe.difficulty && (
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{recipe.sourceUrl && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ExternalLink className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
<TooltipContent>{t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{recipe.isBatchCook && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ChefHat className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
<TooltipContent>{t("batchCookBadge")}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="p-1.5 inline-flex"><VisibilityIcon className="h-3 w-3" /></span>} />
<TooltipContent>{t(`visibility.${recipe.visibility}`)}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
);
}