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>
This commit is contained in:
@@ -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.
|
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
|
## 0.50.1 — 2026-07-19 09:30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { FollowButton } from "@/components/social/follow-button";
|
|||||||
import { BlockButton } from "@/components/social/block-button";
|
import { BlockButton } from "@/components/social/block-button";
|
||||||
import { MessageButton } from "@/components/social/message-button";
|
import { MessageButton } from "@/components/social/message-button";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
|
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
|
||||||
import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs";
|
import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs";
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
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"
|
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
|
<RecipeCoverPlaceholder recipe={recipe} />
|
||||||
🍽️
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Clock, Users, Lock, Globe, Link2, ExternalLink, UserCheck } from "lucide-react";
|
import { Clock, Users, Lock, Globe, Link2, ExternalLink, UserCheck } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
|
|
||||||
type Recipe = {
|
type Recipe = {
|
||||||
@@ -57,9 +58,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="aspect-video bg-gradient-to-br from-muted to-muted/50 flex items-center justify-center text-4xl">
|
<RecipeCoverPlaceholder recipe={recipe} className="aspect-video" iconClassName="h-12 w-12" />
|
||||||
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<h3 className="font-semibold leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
<h3 className="font-semibold leading-tight line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={cn("w-full h-full flex items-center justify-center bg-gradient-to-br", gradient, className)}>
|
||||||
|
<Icon className={cn("h-10 w-10 text-foreground/30", iconClassName)} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Globe, Lock, Link2, ExternalLink, ChefHat, Clock, Users, Utensils, User
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||||
|
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
import { cn, stripMarkdown } from "@/lib/utils";
|
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"
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center text-2xl">
|
<RecipeCoverPlaceholder recipe={recipe} />
|
||||||
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||||
|
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
import { useLocale } from "@/lib/i18n/provider";
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
@@ -92,9 +93,10 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={cn("w-full h-full flex items-center justify-center text-2xl", selectMode && !selected && "opacity-60")}>
|
<RecipeCoverPlaceholder
|
||||||
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
|
recipe={recipe}
|
||||||
</div>
|
className={cn(selectMode && !selected && "opacity-60")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{selectMode && selected && <div className="absolute inset-0 bg-primary/20" />}
|
{selectMode && selected && <div className="absolute inset-0 bg-primary/20" />}
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.50.1",
|
||||||
date: "2026-07-19 09:30",
|
date: "2026-07-19 09:30",
|
||||||
|
|||||||
@@ -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]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.50.1",
|
"version": "0.50.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.50.1",
|
"version": "0.50.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user