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
@@ -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;