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:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.51.0 — 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.
|
||||||
|
|
||||||
## 0.50.2 — 2026-07-19 10:15
|
## 0.50.2 — 2026-07-19 10:15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export default async function ExplorePage({
|
|||||||
isBatchCook: recipes.isBatchCook,
|
isBatchCook: recipes.isBatchCook,
|
||||||
sourceUrl: recipes.sourceUrl,
|
sourceUrl: recipes.sourceUrl,
|
||||||
recipeType: recipes.recipeType,
|
recipeType: recipes.recipeType,
|
||||||
|
coverIcon: recipes.coverIcon,
|
||||||
|
coverColor: recipes.coverColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trending: public recipes ordered by favorite count in last 7 days
|
// 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,
|
isCover: photo.isCover,
|
||||||
preview: getPublicUrl(photo.storageKey),
|
preview: getPublicUrl(photo.storageKey),
|
||||||
})),
|
})),
|
||||||
|
coverIcon: recipe.coverIcon,
|
||||||
|
coverColor: recipe.coverColor,
|
||||||
isBatchCook: recipe.isBatchCook,
|
isBatchCook: recipe.isBatchCook,
|
||||||
// Only pre-fill the manual-nutrition editor with existing values when
|
// Only pre-fill the manual-nutrition editor with existing values when
|
||||||
// they were actually manually entered — AI-estimated data shouldn't
|
// they were actually manually entered — AI-estimated data shouldn't
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export async function GET(req: NextRequest) {
|
|||||||
isBatchCook: recipes.isBatchCook,
|
isBatchCook: recipes.isBatchCook,
|
||||||
sourceUrl: recipes.sourceUrl,
|
sourceUrl: recipes.sourceUrl,
|
||||||
recipeType: recipes.recipeType,
|
recipeType: recipes.recipeType,
|
||||||
|
coverIcon: recipes.coverIcon,
|
||||||
|
coverColor: recipes.coverColor,
|
||||||
})
|
})
|
||||||
.from(recipes)
|
.from(recipes)
|
||||||
.innerJoin(users, eq(recipes.authorId, users.id))
|
.innerJoin(users, eq(recipes.authorId, users.id))
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export async function GET(req: NextRequest) {
|
|||||||
isBatchCook: recipes.isBatchCook,
|
isBatchCook: recipes.isBatchCook,
|
||||||
sourceUrl: recipes.sourceUrl,
|
sourceUrl: recipes.sourceUrl,
|
||||||
recipeType: recipes.recipeType,
|
recipeType: recipes.recipeType,
|
||||||
|
coverIcon: recipes.coverIcon,
|
||||||
|
coverColor: recipes.coverColor,
|
||||||
createdAt: recipes.createdAt,
|
createdAt: recipes.createdAt,
|
||||||
updatedAt: recipes.updatedAt,
|
updatedAt: recipes.updatedAt,
|
||||||
authorId: recipes.authorId,
|
authorId: recipes.authorId,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const UpdateRecipeSchema = z.object({
|
|||||||
key: z.string().min(1).max(500),
|
key: z.string().min(1).max(500),
|
||||||
isCover: z.boolean().default(false),
|
isCover: z.boolean().default(false),
|
||||||
})).max(20).optional(),
|
})).max(20).optional(),
|
||||||
|
coverIcon: z.string().max(50).nullable().optional(),
|
||||||
|
coverColor: z.string().max(50).nullable().optional(),
|
||||||
isBatchCook: z.boolean().optional(),
|
isBatchCook: z.boolean().optional(),
|
||||||
dishes: z.array(z.object({
|
dishes: z.array(z.object({
|
||||||
name: z.string().min(1).max(100),
|
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.tags !== undefined) updates.tags = data.tags;
|
||||||
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||||
if (data.isBatchCook !== undefined) updates.isBatchCook = data.isBatchCook;
|
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) {
|
if (data.nutrition !== undefined) {
|
||||||
updates.nutritionData = data.nutrition ? { perServing: data.nutrition } : null;
|
updates.nutritionData = data.nutrition ? { perServing: data.nutrition } : null;
|
||||||
updates.nutritionManual = !!data.nutrition;
|
updates.nutritionManual = !!data.nutrition;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const CreateRecipeSchema = z.object({
|
|||||||
key: z.string().min(1).max(500),
|
key: z.string().min(1).max(500),
|
||||||
isCover: z.boolean().default(false),
|
isCover: z.boolean().default(false),
|
||||||
})).max(20).default([]),
|
})).max(20).default([]),
|
||||||
|
coverIcon: z.string().max(50).nullable().optional(),
|
||||||
|
coverColor: z.string().max(50).nullable().optional(),
|
||||||
isBatchCook: z.boolean().default(false),
|
isBatchCook: z.boolean().default(false),
|
||||||
dishes: z.array(z.object({
|
dishes: z.array(z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -142,6 +144,8 @@ export async function POST(req: NextRequest) {
|
|||||||
aiGenerated: data.aiGenerated ?? false,
|
aiGenerated: data.aiGenerated ?? false,
|
||||||
language: data.language,
|
language: data.language,
|
||||||
sourceUrl: data.sourceUrl,
|
sourceUrl: data.sourceUrl,
|
||||||
|
coverIcon: data.coverIcon ?? null,
|
||||||
|
coverColor: data.coverColor ?? null,
|
||||||
isBatchCook: data.isBatchCook,
|
isBatchCook: data.isBatchCook,
|
||||||
nutritionData: data.nutrition ? { perServing: data.nutrition } : undefined,
|
nutritionData: data.nutrition ? { perServing: data.nutrition } : undefined,
|
||||||
nutritionManual: !!data.nutrition,
|
nutritionManual: !!data.nutrition,
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export async function GET(req: NextRequest) {
|
|||||||
isBatchCook: recipes.isBatchCook,
|
isBatchCook: recipes.isBatchCook,
|
||||||
sourceUrl: recipes.sourceUrl,
|
sourceUrl: recipes.sourceUrl,
|
||||||
recipeType: recipes.recipeType,
|
recipeType: recipes.recipeType,
|
||||||
|
coverIcon: recipes.coverIcon,
|
||||||
|
coverColor: recipes.coverColor,
|
||||||
authorId: recipes.authorId,
|
authorId: recipes.authorId,
|
||||||
authorName: users.name,
|
authorName: users.name,
|
||||||
createdAt: recipes.createdAt,
|
createdAt: recipes.createdAt,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type Recipe = {
|
|||||||
visibility: "private" | "unlisted" | "public" | "followers";
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
|
coverIcon?: string | null;
|
||||||
|
coverColor?: string | null;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
recipeType?: "dish" | "drink";
|
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,
|
className,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
}: {
|
}: {
|
||||||
recipe: { id: string; recipeType?: "dish" | "drink" };
|
recipe: { id: string; recipeType?: "dish" | "drink"; coverIcon?: string | null; coverColor?: string | null };
|
||||||
className?: string;
|
className?: string;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { gradient, Icon } = getRecipePlaceholder(recipe);
|
const { gradient, Icon } = getRecipePlaceholder(recipe);
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full h-full flex items-center justify-center bg-gradient-to-br", gradient, className)}>
|
<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/30", iconClassName)} strokeWidth={1.5} />
|
<Icon className={cn("h-10 w-10 text-foreground/25", iconClassName)} strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { DietaryTagPicker } from "./dietary-tag-picker";
|
import { DietaryTagPicker } from "./dietary-tag-picker";
|
||||||
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
|
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
|
||||||
|
import { RecipeCoverPicker } from "./recipe-cover-picker";
|
||||||
import { RegenerateRecipeButton } from "./regenerate-recipe-button";
|
import { RegenerateRecipeButton } from "./regenerate-recipe-button";
|
||||||
import type { RegeneratedRecipe } from "@/lib/ai/features/regenerate-recipe";
|
import type { RegeneratedRecipe } from "@/lib/ai/features/regenerate-recipe";
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@ type RecipeFormProps = {
|
|||||||
ingredients?: IngredientRow[];
|
ingredients?: IngredientRow[];
|
||||||
steps?: StepRow[];
|
steps?: StepRow[];
|
||||||
photos?: PhotoEntry[];
|
photos?: PhotoEntry[];
|
||||||
|
coverIcon?: string | null;
|
||||||
|
coverColor?: string | null;
|
||||||
isBatchCook?: boolean;
|
isBatchCook?: boolean;
|
||||||
dishes?: DishRow[];
|
dishes?: DishRow[];
|
||||||
nutrition?: {
|
nutrition?: {
|
||||||
@@ -155,6 +158,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
|
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
|
||||||
);
|
);
|
||||||
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
|
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 [isBatchCook, setIsBatchCook] = useState(defaultValues?.isBatchCook ?? false);
|
||||||
const [dishes, setDishes] = useState<DishRow[]>(
|
const [dishes, setDishes] = useState<DishRow[]>(
|
||||||
defaultValues?.dishes?.length ? defaultValues.dishes : [newDish()]
|
defaultValues?.dishes?.length ? defaultValues.dishes : [newDish()]
|
||||||
@@ -376,6 +381,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
ingredients: filteredIngredients,
|
ingredients: filteredIngredients,
|
||||||
steps: filteredSteps,
|
steps: filteredSteps,
|
||||||
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
|
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
|
||||||
|
coverIcon,
|
||||||
|
coverColor,
|
||||||
isBatchCook,
|
isBatchCook,
|
||||||
dishes: filteredDishes,
|
dishes: filteredDishes,
|
||||||
nutrition: addNutrition
|
nutrition: addNutrition
|
||||||
@@ -684,6 +691,20 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
<PhotoUploader recipeId={id} photos={photos} onChange={setPhotos} />
|
<PhotoUploader recipeId={id} photos={photos} onChange={setPhotos} />
|
||||||
</div>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
{/* Ingredients */}
|
{/* Ingredients */}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type GridCardRecipe = {
|
|||||||
visibility: "private" | "unlisted" | "public" | "followers";
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
tags: string[];
|
tags: string[];
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
|
coverIcon?: string | null;
|
||||||
|
coverColor?: string | null;
|
||||||
isFavorited?: boolean;
|
isFavorited?: boolean;
|
||||||
isBatchCook?: boolean;
|
isBatchCook?: boolean;
|
||||||
dishCount?: number;
|
dishCount?: number;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ type Recipe = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
|
coverIcon?: string | null;
|
||||||
|
coverColor?: string | null;
|
||||||
isFavorited?: boolean;
|
isFavorited?: boolean;
|
||||||
isBatchCook?: boolean;
|
isBatchCook?: boolean;
|
||||||
dishCount?: number;
|
dishCount?: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.50.2",
|
||||||
date: "2026-07-19 10:15",
|
date: "2026-07-19 10:15",
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().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(),
|
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."),
|
nutritionManual: z.boolean().describe("True if nutritionData was entered by the author rather than AI-estimated."),
|
||||||
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||||
@@ -77,6 +79,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||||
language: z.string().nullable(), sourceUrl: z.string().nullable(),
|
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(),
|
isBatchCook: z.boolean(), nutritionData: z.object({ perServing: NutritionInputRef }).nullable(),
|
||||||
nutritionManual: z.boolean(),
|
nutritionManual: z.boolean(),
|
||||||
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||||
@@ -113,6 +116,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
key: z.string().min(1).max(500),
|
key: z.string().min(1).max(500),
|
||||||
isCover: z.boolean().default(false),
|
isCover: z.boolean().default(false),
|
||||||
})).max(20).default([]),
|
})).max(20).default([]),
|
||||||
|
coverIcon: z.string().max(50).nullable().optional(),
|
||||||
|
coverColor: z.string().max(50).nullable().optional(),
|
||||||
isBatchCook: z.boolean().default(false),
|
isBatchCook: z.boolean().default(false),
|
||||||
dishes: z.array(z.object({
|
dishes: z.array(z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -155,6 +160,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
key: z.string().min(1).max(500),
|
key: z.string().min(1).max(500),
|
||||||
isCover: z.boolean().default(false),
|
isCover: z.boolean().default(false),
|
||||||
})).max(20).optional(),
|
})).max(20).optional(),
|
||||||
|
coverIcon: z.string().max(50).nullable().optional(),
|
||||||
|
coverColor: z.string().max(50).nullable().optional(),
|
||||||
isBatchCook: z.boolean().optional(),
|
isBatchCook: z.boolean().optional(),
|
||||||
dishes: z.array(z.object({
|
dishes: z.array(z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
|
|||||||
@@ -5,23 +5,52 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const GRADIENTS = [
|
// Muted on purpose — these sit behind a lucide icon on every photo-less card
|
||||||
"from-orange-200 to-rose-200 dark:from-orange-950 dark:to-rose-950",
|
// in a grid, so anything more saturated than "barely-there tint" overpowers
|
||||||
"from-amber-200 to-lime-200 dark:from-amber-950 dark:to-lime-950",
|
// real cover photos next to it and reads as loud rather than as a placeholder.
|
||||||
"from-sky-200 to-indigo-200 dark:from-sky-950 dark:to-indigo-950",
|
export const GRADIENT_OPTIONS = [
|
||||||
"from-emerald-200 to-teal-200 dark:from-emerald-950 dark:to-teal-950",
|
{ key: "orange-rose", classes: "from-orange-100/70 to-rose-100/70 dark:from-orange-950/40 dark:to-rose-950/40" },
|
||||||
"from-fuchsia-200 to-purple-200 dark:from-fuchsia-950 dark:to-purple-950",
|
{ key: "amber-lime", classes: "from-amber-100/70 to-lime-100/70 dark:from-amber-950/40 dark:to-lime-950/40" },
|
||||||
"from-yellow-200 to-orange-200 dark:from-yellow-950 dark:to-orange-950",
|
{ key: "sky-indigo", classes: "from-sky-100/70 to-indigo-100/70 dark:from-sky-950/40 dark:to-indigo-950/40" },
|
||||||
"from-rose-200 to-pink-200 dark:from-rose-950 dark:to-pink-950",
|
{ key: "emerald-teal", classes: "from-emerald-100/70 to-teal-100/70 dark:from-emerald-950/40 dark:to-teal-950/40" },
|
||||||
"from-teal-200 to-cyan-200 dark:from-teal-950 dark:to-cyan-950",
|
{ 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[] = [
|
export const DISH_ICON_OPTIONS = [
|
||||||
ChefHat, Soup, Salad, Pizza, Sandwich, IceCream, Cookie, Beef, Fish,
|
{ key: "chef-hat", Icon: ChefHat },
|
||||||
Cherry, Carrot, Egg, Drumstick, Croissant, Donut, Cake,
|
{ 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. */
|
/** Small, fast, deterministic string hash (djb2) — same recipe always gets the same placeholder. */
|
||||||
function hashString(s: string): number {
|
function hashString(s: string): number {
|
||||||
@@ -32,16 +61,27 @@ function hashString(s: string): number {
|
|||||||
return hash >>> 0;
|
return hash >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecipePlaceholder(recipe: { id: string; recipeType?: "dish" | "drink" }): {
|
/** Resolves a recipe's cover placeholder — user-chosen coverIcon/coverColor
|
||||||
gradient: string;
|
* win when set and recognized, else falls back to a deterministic pick
|
||||||
Icon: LucideIcon;
|
* 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 hash = hashString(recipe.id);
|
||||||
const icons = recipe.recipeType === "drink" ? DRINK_ICONS : DISH_ICONS;
|
const dishOrDrinkIcons = recipe.recipeType === "drink" ? DRINK_ICON_OPTIONS : DISH_ICON_OPTIONS;
|
||||||
return {
|
|
||||||
gradient: GRADIENTS[hash % GRADIENTS.length]!,
|
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
|
// Different modulus base than gradient's so icon/color pairing doesn't
|
||||||
// repeat in lockstep across ids that happen to share a gradient.
|
// 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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)",
|
"calories": "Calories (kcal)",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
|
"cover": "Cover",
|
||||||
|
"coverHint": "Only used when this recipe has no photo.",
|
||||||
|
"coverReset": "Reset to automatic",
|
||||||
"ingredients": "Ingredients",
|
"ingredients": "Ingredients",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"unit": "Unit",
|
"unit": "Unit",
|
||||||
|
|||||||
@@ -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.",
|
"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)",
|
"calories": "Calories (kcal)",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
|
"cover": "Couverture",
|
||||||
|
"coverHint": "Utilisé uniquement si cette recette n'a pas de photo.",
|
||||||
|
"coverReset": "Réinitialiser en automatique",
|
||||||
"ingredients": "Ingrédients",
|
"ingredients": "Ingrédients",
|
||||||
"amount": "Quantité",
|
"amount": "Quantité",
|
||||||
"unit": "Unité",
|
"unit": "Unité",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.50.2",
|
"version": "0.51.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.50.2",
|
"version": "0.51.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "recipes" ADD COLUMN "cover_icon" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "recipes" ADD COLUMN "cover_color" text;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -337,6 +337,13 @@
|
|||||||
"when": 1784394631882,
|
"when": 1784394631882,
|
||||||
"tag": "0047_acoustic_goblin_queen",
|
"tag": "0047_acoustic_goblin_queen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784449674049,
|
||||||
|
"tag": "0048_normal_shiver_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,10 @@ export const recipes = pgTable("recipes", {
|
|||||||
cookMins: integer("cook_mins"),
|
cookMins: integer("cook_mins"),
|
||||||
tags: text("tags").array().notNull().default([]),
|
tags: text("tags").array().notNull().default([]),
|
||||||
isBatchCook: boolean("is_batch_cook").notNull().default(false),
|
isBatchCook: boolean("is_batch_cook").notNull().default(false),
|
||||||
|
// User-chosen cover for when there's no photo — null falls back to the
|
||||||
|
// deterministic hash-based placeholder (see lib/recipe-placeholder.ts).
|
||||||
|
coverIcon: text("cover_icon"),
|
||||||
|
coverColor: text("cover_color"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user