Files
Epicure/apps/web/components/recipe/recipe-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

99 lines
3.5 KiB
TypeScript

"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Clock, Users, Lock, Globe, Link2, ExternalLink, UserCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
import { getPublicUrl } from "@/lib/storage";
type Recipe = {
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";
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
sourceUrl?: string | null;
recipeType?: "dish" | "drink";
};
const VISIBILITY_ICON = {
private: Lock,
unlisted: Link2,
public: Globe,
followers: UserCheck,
};
const DIFFICULTY_COLOR = {
easy: "default",
medium: "secondary",
hard: "destructive",
} as const;
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const t = useTranslations("recipe");
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
<Link href={`/recipes/${recipe.id}`}>
<Card className="group overflow-hidden hover:shadow-md transition-shadow h-full flex flex-col">
{cover ? (
<div className="relative aspect-video overflow-hidden bg-muted">
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
) : (
<RecipeCoverPlaceholder recipe={recipe} className="aspect-video" iconClassName="h-12 w-12" />
)}
<CardHeader className="pb-2">
<h3 className="font-semibold leading-tight line-clamp-2 group-hover:text-primary transition-colors">
{recipe.title}
</h3>
{recipe.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{recipe.description}</p>
)}
</CardHeader>
<CardContent className="pb-2 flex-1" />
<CardFooter className="pt-4 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<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">
{t(`difficulty.${recipe.difficulty}`)}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{recipe.sourceUrl && <ExternalLink className="h-3 w-3" />}
<VisibilityIcon className="h-3 w-3" />
</div>
</CardFooter>
</Card>
</Link>
);
}