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