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>
This commit is contained in:
Arnaud
2026-07-19 10:32:31 +02:00
parent 955c4bd9dd
commit 51e6722f4c
25 changed files with 5721 additions and 30 deletions
+2
View File
@@ -61,6 +61,8 @@ export default async function ExplorePage({
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
coverIcon: recipes.coverIcon,
coverColor: recipes.coverColor,
};
// Trending: public recipes ordered by favorite count in last 7 days
@@ -60,6 +60,8 @@ export default async function EditRecipePage({ params }: Params) {
isCover: photo.isCover,
preview: getPublicUrl(photo.storageKey),
})),
coverIcon: recipe.coverIcon,
coverColor: recipe.coverColor,
isBatchCook: recipe.isBatchCook,
// Only pre-fill the manual-nutrition editor with existing values when
// they were actually manually entered — AI-estimated data shouldn't
@@ -51,6 +51,8 @@ export async function GET(req: NextRequest) {
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
coverIcon: recipes.coverIcon,
coverColor: recipes.coverColor,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
+2
View File
@@ -41,6 +41,8 @@ export async function GET(req: NextRequest) {
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
coverIcon: recipes.coverIcon,
coverColor: recipes.coverColor,
createdAt: recipes.createdAt,
updatedAt: recipes.updatedAt,
authorId: recipes.authorId,
@@ -47,6 +47,8 @@ const UpdateRecipeSchema = z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).optional(),
coverIcon: z.string().max(50).nullable().optional(),
coverColor: z.string().max(50).nullable().optional(),
isBatchCook: z.boolean().optional(),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
@@ -169,6 +171,8 @@ export async function PUT(req: NextRequest, { params }: Params) {
if (data.tags !== undefined) updates.tags = data.tags;
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
if (data.isBatchCook !== undefined) updates.isBatchCook = data.isBatchCook;
if (data.coverIcon !== undefined) updates.coverIcon = data.coverIcon;
if (data.coverColor !== undefined) updates.coverColor = data.coverColor;
if (data.nutrition !== undefined) {
updates.nutritionData = data.nutrition ? { perServing: data.nutrition } : null;
updates.nutritionManual = !!data.nutrition;
+4
View File
@@ -51,6 +51,8 @@ const CreateRecipeSchema = z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).default([]),
coverIcon: z.string().max(50).nullable().optional(),
coverColor: z.string().max(50).nullable().optional(),
isBatchCook: z.boolean().default(false),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
@@ -142,6 +144,8 @@ export async function POST(req: NextRequest) {
aiGenerated: data.aiGenerated ?? false,
language: data.language,
sourceUrl: data.sourceUrl,
coverIcon: data.coverIcon ?? null,
coverColor: data.coverColor ?? null,
isBatchCook: data.isBatchCook,
nutritionData: data.nutrition ? { perServing: data.nutrition } : undefined,
nutritionManual: !!data.nutrition,
+2
View File
@@ -137,6 +137,8 @@ export async function GET(req: NextRequest) {
isBatchCook: recipes.isBatchCook,
sourceUrl: recipes.sourceUrl,
recipeType: recipes.recipeType,
coverIcon: recipes.coverIcon,
coverColor: recipes.coverColor,
authorId: recipes.authorId,
authorName: users.name,
createdAt: recipes.createdAt,
@@ -20,6 +20,8 @@ type Recipe = {
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";
};
@@ -0,0 +1,80 @@
"use client";
import { Shuffle } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { GRADIENT_OPTIONS, DISH_ICON_OPTIONS, DRINK_ICON_OPTIONS } from "@/lib/recipe-placeholder";
/** Lets the user pin a specific color + icon for a recipe's cover placeholder,
* used whenever there's no uploaded photo. Leaving both unset (null) keeps
* the automatic per-recipe pick from getRecipePlaceholder. */
export function RecipeCoverPicker({
recipeType,
color,
icon,
onChange,
}: {
recipeType: "dish" | "drink";
color: string | null;
icon: string | null;
onChange: (next: { color: string | null; icon: string | null }) => void;
}) {
const t = useTranslations("recipeForm");
const iconOptions = recipeType === "drink" ? DRINK_ICON_OPTIONS : DISH_ICON_OPTIONS;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{t("coverHint")}</p>
{(color || icon) && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1 text-muted-foreground"
onClick={() => onChange({ color: null, icon: null })}
>
<Shuffle className="h-3 w-3" />
{t("coverReset")}
</Button>
)}
</div>
<div className="flex flex-wrap gap-2">
{GRADIENT_OPTIONS.map((g) => (
<button
key={g.key}
type="button"
onClick={() => onChange({ color: g.key, icon })}
className={cn(
"h-7 w-7 rounded-full bg-gradient-to-br border-2 transition-transform",
g.classes,
color === g.key ? "border-foreground scale-110" : "border-transparent hover:scale-105"
)}
aria-label={`Cover color ${g.key}`}
aria-pressed={color === g.key}
/>
))}
</div>
<div className="flex flex-wrap gap-2">
{iconOptions.map(({ key, Icon }) => (
<button
key={key}
type="button"
onClick={() => onChange({ color, icon: key })}
className={cn(
"h-8 w-8 rounded-lg border flex items-center justify-center transition-colors",
icon === key ? "border-foreground bg-accent" : "border-transparent hover:bg-accent/50"
)}
aria-label={`Cover icon ${key}`}
aria-pressed={icon === key}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
</button>
))}
</div>
</div>
);
}
@@ -7,14 +7,14 @@ export function RecipeCoverPlaceholder({
className,
iconClassName,
}: {
recipe: { id: string; recipeType?: "dish" | "drink" };
recipe: { id: string; recipeType?: "dish" | "drink"; coverIcon?: string | null; coverColor?: string | null };
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 className={cn("w-full h-full flex items-center justify-center bg-muted bg-gradient-to-br", gradient, className)}>
<Icon className={cn("h-10 w-10 text-foreground/25", iconClassName)} strokeWidth={1.5} />
</div>
);
}
@@ -24,6 +24,7 @@ import {
} from "@/components/ui/alert-dialog";
import { DietaryTagPicker } from "./dietary-tag-picker";
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
import { RecipeCoverPicker } from "./recipe-cover-picker";
import { RegenerateRecipeButton } from "./regenerate-recipe-button";
import type { RegeneratedRecipe } from "@/lib/ai/features/regenerate-recipe";
@@ -78,6 +79,8 @@ type RecipeFormProps = {
ingredients?: IngredientRow[];
steps?: StepRow[];
photos?: PhotoEntry[];
coverIcon?: string | null;
coverColor?: string | null;
isBatchCook?: boolean;
dishes?: DishRow[];
nutrition?: {
@@ -155,6 +158,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
);
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
const [coverIcon, setCoverIcon] = useState<string | null>(defaultValues?.coverIcon ?? null);
const [coverColor, setCoverColor] = useState<string | null>(defaultValues?.coverColor ?? null);
const [isBatchCook, setIsBatchCook] = useState(defaultValues?.isBatchCook ?? false);
const [dishes, setDishes] = useState<DishRow[]>(
defaultValues?.dishes?.length ? defaultValues.dishes : [newDish()]
@@ -376,6 +381,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
ingredients: filteredIngredients,
steps: filteredSteps,
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
coverIcon,
coverColor,
isBatchCook,
dishes: filteredDishes,
nutrition: addNutrition
@@ -684,6 +691,20 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<PhotoUploader recipeId={id} photos={photos} onChange={setPhotos} />
</div>
{/* Cover placeholder (used when there's no photo above) */}
<div className="space-y-2">
<Label>{t("cover")}</Label>
<RecipeCoverPicker
recipeType={recipeType}
color={coverColor}
icon={coverIcon}
onChange={({ color, icon }) => {
setCoverColor(color);
setCoverIcon(icon);
}}
/>
</div>
<Separator />
{/* Ingredients */}
@@ -21,6 +21,8 @@ export type GridCardRecipe = {
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;
@@ -46,6 +46,8 @@ type Recipe = {
tags: string[];
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
coverIcon?: string | null;
coverColor?: string | null;
isFavorited?: boolean;
isBatchCook?: boolean;
dishCount?: number;
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.50.2";
export const APP_VERSION = "0.51.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.51.0",
date: "2026-07-19 11:40",
added: [
"Pick your own recipe cover color + icon in the recipe editor (below Photos) — used whenever that recipe has no photo. Also toned down the auto-generated placeholder colors from the previous release, which were too saturated next to real cover photos.",
],
},
{
version: "0.50.2",
date: "2026-07-19 10:15",
+7
View File
@@ -62,6 +62,8 @@ export function generateOpenApiSpec(): object {
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
coverIcon: z.string().nullable().describe("Cover placeholder icon key, used when there's no photo. Null falls back to a deterministic pick from the recipe id."),
coverColor: z.string().nullable().describe("Cover placeholder gradient key, used when there's no photo. Null falls back to a deterministic pick from the recipe id."),
isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(),
nutritionManual: z.boolean().describe("True if nutritionData was entered by the author rather than AI-estimated."),
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
@@ -77,6 +79,7 @@ export function generateOpenApiSpec(): object {
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
language: z.string().nullable(), sourceUrl: z.string().nullable(),
coverIcon: z.string().nullable(), coverColor: z.string().nullable(),
isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(),
nutritionManual: z.boolean(),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
@@ -113,6 +116,8 @@ export function generateOpenApiSpec(): object {
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).default([]),
coverIcon: z.string().max(50).nullable().optional(),
coverColor: z.string().max(50).nullable().optional(),
isBatchCook: z.boolean().default(false),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
@@ -155,6 +160,8 @@ export function generateOpenApiSpec(): object {
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).optional(),
coverIcon: z.string().max(50).nullable().optional(),
coverColor: z.string().max(50).nullable().optional(),
isBatchCook: z.boolean().optional(),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
+64 -24
View File
@@ -5,23 +5,52 @@ import {
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",
];
// Muted on purpose — these sit behind a lucide icon on every photo-less card
// in a grid, so anything more saturated than "barely-there tint" overpowers
// real cover photos next to it and reads as loud rather than as a placeholder.
export const GRADIENT_OPTIONS = [
{ key: "orange-rose", classes: "from-orange-100/70 to-rose-100/70 dark:from-orange-950/40 dark:to-rose-950/40" },
{ key: "amber-lime", classes: "from-amber-100/70 to-lime-100/70 dark:from-amber-950/40 dark:to-lime-950/40" },
{ key: "sky-indigo", classes: "from-sky-100/70 to-indigo-100/70 dark:from-sky-950/40 dark:to-indigo-950/40" },
{ key: "emerald-teal", classes: "from-emerald-100/70 to-teal-100/70 dark:from-emerald-950/40 dark:to-teal-950/40" },
{ key: "fuchsia-purple", classes: "from-fuchsia-100/70 to-purple-100/70 dark:from-fuchsia-950/40 dark:to-purple-950/40" },
{ key: "yellow-orange", classes: "from-yellow-100/70 to-orange-100/70 dark:from-yellow-950/40 dark:to-orange-950/40" },
{ key: "rose-pink", classes: "from-rose-100/70 to-pink-100/70 dark:from-rose-950/40 dark:to-pink-950/40" },
{ key: "teal-cyan", classes: "from-teal-100/70 to-cyan-100/70 dark:from-teal-950/40 dark:to-cyan-950/40" },
] as const;
const DISH_ICONS: LucideIcon[] = [
ChefHat, Soup, Salad, Pizza, Sandwich, IceCream, Cookie, Beef, Fish,
Cherry, Carrot, Egg, Drumstick, Croissant, Donut, Cake,
];
export const DISH_ICON_OPTIONS = [
{ key: "chef-hat", Icon: ChefHat },
{ key: "soup", Icon: Soup },
{ key: "salad", Icon: Salad },
{ key: "pizza", Icon: Pizza },
{ key: "sandwich", Icon: Sandwich },
{ key: "ice-cream", Icon: IceCream },
{ key: "cookie", Icon: Cookie },
{ key: "beef", Icon: Beef },
{ key: "fish", Icon: Fish },
{ key: "cherry", Icon: Cherry },
{ key: "carrot", Icon: Carrot },
{ key: "egg", Icon: Egg },
{ key: "drumstick", Icon: Drumstick },
{ key: "croissant", Icon: Croissant },
{ key: "donut", Icon: Donut },
{ key: "cake", Icon: Cake },
] as const;
const DRINK_ICONS: LucideIcon[] = [Coffee, Wine, Beer, GlassWater, Martini, CupSoda];
export const DRINK_ICON_OPTIONS = [
{ key: "coffee", Icon: Coffee },
{ key: "wine", Icon: Wine },
{ key: "beer", Icon: Beer },
{ key: "glass-water", Icon: GlassWater },
{ key: "martini", Icon: Martini },
{ key: "cup-soda", Icon: CupSoda },
] as const;
export const ALL_ICON_OPTIONS: { key: string; Icon: LucideIcon }[] = [...DISH_ICON_OPTIONS, ...DRINK_ICON_OPTIONS];
const ICON_BY_KEY = new Map<string, LucideIcon>(ALL_ICON_OPTIONS.map((o) => [o.key, o.Icon]));
const GRADIENT_BY_KEY = new Map<string, string>(GRADIENT_OPTIONS.map((o) => [o.key, o.classes]));
/** Small, fast, deterministic string hash (djb2) — same recipe always gets the same placeholder. */
function hashString(s: string): number {
@@ -32,16 +61,27 @@ function hashString(s: string): number {
return hash >>> 0;
}
export function getRecipePlaceholder(recipe: { id: string; recipeType?: "dish" | "drink" }): {
gradient: string;
Icon: LucideIcon;
} {
/** Resolves a recipe's cover placeholder user-chosen coverIcon/coverColor
* win when set and recognized, else falls back to a deterministic pick
* from the recipe id so it's still stable without a fresh hash on rename. */
export function getRecipePlaceholder(recipe: {
id: string;
recipeType?: "dish" | "drink";
coverIcon?: string | null;
coverColor?: string | null;
}): { gradient: string; Icon: LucideIcon } {
const hash = hashString(recipe.id);
const icons = recipe.recipeType === "drink" ? DRINK_ICONS : DISH_ICONS;
return {
gradient: GRADIENTS[hash % GRADIENTS.length]!,
const dishOrDrinkIcons = recipe.recipeType === "drink" ? DRINK_ICON_OPTIONS : DISH_ICON_OPTIONS;
const gradient =
(recipe.coverColor ? GRADIENT_BY_KEY.get(recipe.coverColor) : undefined) ??
GRADIENT_OPTIONS[hash % GRADIENT_OPTIONS.length]!.classes;
const Icon =
(recipe.coverIcon ? ICON_BY_KEY.get(recipe.coverIcon) : undefined) ??
// 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]!,
};
dishOrDrinkIcons[Math.floor(hash / GRADIENT_OPTIONS.length) % dishOrDrinkIcons.length]!.Icon;
return { gradient, Icon };
}
+3
View File
@@ -872,6 +872,9 @@
"nutritionToggleHelp": "Per serving. Turns off the AI-estimate action on the recipe page — clear these fields (turn this off) to let AI estimate it instead.",
"calories": "Calories (kcal)",
"photos": "Photos",
"cover": "Cover",
"coverHint": "Only used when this recipe has no photo.",
"coverReset": "Reset to automatic",
"ingredients": "Ingredients",
"amount": "Amount",
"unit": "Unit",
+3
View File
@@ -863,6 +863,9 @@
"nutritionToggleHelp": "Par portion. Désactive l'estimation IA sur la page de la recette — videz ces champs (désactivez ceci) pour laisser l'IA l'estimer à nouveau.",
"calories": "Calories (kcal)",
"photos": "Photos",
"cover": "Couverture",
"coverHint": "Utilisé uniquement si cette recette n'a pas de photo.",
"coverReset": "Réinitialiser en automatique",
"ingredients": "Ingrédients",
"amount": "Quantité",
"unit": "Unité",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.50.2",
"version": "0.51.0",
"private": true,
"scripts": {
"dev": "next dev",