Files
Epicure/apps/web/components/recipe/recipe-grid-card.tsx
T
Arnaud 51e6722f4c feat: custom recipe cover color/icon + mute auto placeholder palette (v0.51.0)
Two changes to the no-photo cover placeholder shipped last version:

1. Muted the gradient palette (100/40-opacity tints instead of solid -200/
   -950 stops) — the original was too saturated next to real cover photos
   in the same grid, per feedback.
2. New coverIcon/coverColor columns on recipes (nullable text, migration
   0048, additive-only) let the author pin a specific color+icon from the
   recipe editor instead of the automatic per-id pick. getRecipePlaceholder
   now checks these first, falling back to the deterministic hash pick when
   unset — existing recipes are unaffected until edited.

Wired coverIcon/coverColor through every explicit-column recipe select
that feeds a grid card (explore trending/recent, search, feed, for-you) —
the relational-query call sites (recipes page, collections, profile pages)
already return all columns and needed no changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:32:31 +02:00

144 lines
6.2 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 }>;
coverIcon?: string | null;
coverColor?: string | null;
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>
);
}