Files
Epicure/apps/web/components/recipe/recipe-cover-picker.tsx
T
Arnaud 51e6722f4c 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>
2026-07-19 10:32:31 +02:00

81 lines
2.6 KiB
TypeScript

"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>
);
}