From 955c4bd9dd938ff4a4dce3199be7327a5c1b4af0 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 19 Jul 2026 09:47:22 +0200 Subject: [PATCH] fix: varied gradient+icon placeholders for recipes without a cover photo (v0.50.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 5 ++ apps/web/app/(app)/u/[username]/page.tsx | 5 +- apps/web/components/recipe/recipe-card.tsx | 5 +- .../recipe/recipe-cover-placeholder.tsx | 20 ++++++++ .../components/recipe/recipe-grid-card.tsx | 5 +- apps/web/components/recipe/recipes-grid.tsx | 8 ++-- apps/web/lib/changelog.ts | 9 +++- apps/web/lib/recipe-placeholder.ts | 47 +++++++++++++++++++ apps/web/package.json | 2 +- package.json | 2 +- 10 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 apps/web/components/recipe/recipe-cover-placeholder.tsx create mode 100644 apps/web/lib/recipe-placeholder.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f7938..273f77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.50.2 — 2026-07-19 10:15 + +### Fixed +- Recipes without a cover photo now get a varied gradient + food/drink icon instead of the same flat emoji everywhere — deterministic per recipe, so it stays stable across reloads. Applied to the Recipes grid/list, Explore/collections cards, and public profile pages. + ## 0.50.1 — 2026-07-19 09:30 ### Fixed diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx index 6090efe..2c4771a 100644 --- a/apps/web/app/(app)/u/[username]/page.tsx +++ b/apps/web/app/(app)/u/[username]/page.tsx @@ -24,6 +24,7 @@ import { FollowButton } from "@/components/social/follow-button"; import { BlockButton } from "@/components/social/block-button"; import { MessageButton } from "@/components/social/message-button"; import { getPublicUrl } from "@/lib/storage"; +import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder"; import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs"; const PAGE_SIZE = 24; @@ -247,9 +248,7 @@ export default async function UserProfilePage({ params, searchParams }: Params) className="object-cover group-hover:scale-105 transition-transform duration-200" /> ) : ( -
- 🍽️ -
+ )}
diff --git a/apps/web/components/recipe/recipe-card.tsx b/apps/web/components/recipe/recipe-card.tsx index ff1f542..e3c1ec4 100644 --- a/apps/web/components/recipe/recipe-card.tsx +++ b/apps/web/components/recipe/recipe-card.tsx @@ -6,6 +6,7 @@ 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 = { @@ -57,9 +58,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) { />
) : ( -
- {recipe.recipeType === "drink" ? "🍹" : "🍽️"} -
+ )}

diff --git a/apps/web/components/recipe/recipe-cover-placeholder.tsx b/apps/web/components/recipe/recipe-cover-placeholder.tsx new file mode 100644 index 0000000..5658432 --- /dev/null +++ b/apps/web/components/recipe/recipe-cover-placeholder.tsx @@ -0,0 +1,20 @@ +import { getRecipePlaceholder } from "@/lib/recipe-placeholder"; +import { cn } from "@/lib/utils"; + +/** Gradient + food/drink icon shown in place of a cover photo — deterministic per recipe id, so it stays stable across renders instead of flickering between icons/colors. */ +export function RecipeCoverPlaceholder({ + recipe, + className, + iconClassName, +}: { + recipe: { id: string; recipeType?: "dish" | "drink" }; + className?: string; + iconClassName?: string; +}) { + const { gradient, Icon } = getRecipePlaceholder(recipe); + return ( +
+ +
+ ); +} diff --git a/apps/web/components/recipe/recipe-grid-card.tsx b/apps/web/components/recipe/recipe-grid-card.tsx index e3a28ee..7188e5e 100644 --- a/apps/web/components/recipe/recipe-grid-card.tsx +++ b/apps/web/components/recipe/recipe-grid-card.tsx @@ -6,6 +6,7 @@ import { Globe, Lock, Link2, ExternalLink, ChefHat, Clock, Users, Utensils, User 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"; @@ -54,9 +55,7 @@ export function RecipeThumb({ recipe, className }: { recipe: GridCardRecipe; cla className="object-cover transition-transform duration-300 group-hover:scale-105" /> ) : ( -
- {recipe.recipeType === "drink" ? "🍹" : "🍽️"} -
+ )} ); diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 22fc540..f22de65 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/alert-dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { FavoriteButton } from "@/components/social/favorite-button"; +import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder"; import { toast } from "sonner"; import { getPublicUrl } from "@/lib/storage"; import { useLocale } from "@/lib/i18n/provider"; @@ -92,9 +93,10 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci )} /> ) : ( -
- {recipe.recipeType === "drink" ? "🍹" : "🍽️"} -
+ )} {selectMode && selected &&
} {selectMode && ( diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index c55e0f3..4634374 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.50.1"; +export const APP_VERSION = "0.50.2"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.50.2", + date: "2026-07-19 10:15", + fixed: [ + "Recipes without a cover photo now get a varied gradient + food/drink icon instead of the same flat emoji everywhere — deterministic per recipe, so it stays stable across reloads. Applied to the Recipes grid/list, Explore/collections cards, and public profile pages.", + ], + }, { version: "0.50.1", date: "2026-07-19 09:30", diff --git a/apps/web/lib/recipe-placeholder.ts b/apps/web/lib/recipe-placeholder.ts new file mode 100644 index 0000000..1297edf --- /dev/null +++ b/apps/web/lib/recipe-placeholder.ts @@ -0,0 +1,47 @@ +import { + Soup, Salad, Pizza, Sandwich, IceCream, Cookie, Beef, Fish, Cherry, + Carrot, Egg, Drumstick, Croissant, Donut, Cake, ChefHat, + Coffee, Wine, Beer, GlassWater, Martini, CupSoda, + type LucideIcon, +} from "lucide-react"; + +const GRADIENTS = [ + "from-orange-200 to-rose-200 dark:from-orange-950 dark:to-rose-950", + "from-amber-200 to-lime-200 dark:from-amber-950 dark:to-lime-950", + "from-sky-200 to-indigo-200 dark:from-sky-950 dark:to-indigo-950", + "from-emerald-200 to-teal-200 dark:from-emerald-950 dark:to-teal-950", + "from-fuchsia-200 to-purple-200 dark:from-fuchsia-950 dark:to-purple-950", + "from-yellow-200 to-orange-200 dark:from-yellow-950 dark:to-orange-950", + "from-rose-200 to-pink-200 dark:from-rose-950 dark:to-pink-950", + "from-teal-200 to-cyan-200 dark:from-teal-950 dark:to-cyan-950", +]; + +const DISH_ICONS: LucideIcon[] = [ + ChefHat, Soup, Salad, Pizza, Sandwich, IceCream, Cookie, Beef, Fish, + Cherry, Carrot, Egg, Drumstick, Croissant, Donut, Cake, +]; + +const DRINK_ICONS: LucideIcon[] = [Coffee, Wine, Beer, GlassWater, Martini, CupSoda]; + +/** Small, fast, deterministic string hash (djb2) — same recipe always gets the same placeholder. */ +function hashString(s: string): number { + let hash = 5381; + for (let i = 0; i < s.length; i++) { + hash = (hash * 33) ^ s.charCodeAt(i); + } + return hash >>> 0; +} + +export function getRecipePlaceholder(recipe: { id: string; recipeType?: "dish" | "drink" }): { + gradient: string; + Icon: LucideIcon; +} { + const hash = hashString(recipe.id); + const icons = recipe.recipeType === "drink" ? DRINK_ICONS : DISH_ICONS; + return { + gradient: GRADIENTS[hash % GRADIENTS.length]!, + // Different modulus base than gradient's so icon/color pairing doesn't + // repeat in lockstep across ids that happen to share a gradient. + Icon: icons[Math.floor(hash / GRADIENTS.length) % icons.length]!, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index deb7edc..9c3af3d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.50.1", + "version": "0.50.2", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index e511343..333bbff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.50.1", + "version": "0.50.2", "private": true, "scripts": { "dev": "pnpm --filter web dev",