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

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