51e6722f4c
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>
144 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|