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:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user