546ba98d2f
recipe-form/API routes were never sending/persisting photos on create+update. PhotoUploader accumulated uploads via a stale closure over the photos prop, so only the last file of a multi-select survived. Once both were fixed, thumbnails still 400'd because Next's image optimizer blocks upstream hosts resolving to private/loopback IPs (localhost:9000 MinIO) — skip optimization for storage-hosted photos since they're already web-sized user uploads.
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { useTranslations } from "next-intl";
|
|
import { ChefHat, Clock } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { FavoriteButton } from "@/components/social/favorite-button";
|
|
import { getPublicUrl } from "@/lib/storage";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const DIFFICULTY_COLORS: Record<string, string> = {
|
|
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
|
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
|
};
|
|
|
|
export type FavoriteRecipe = {
|
|
id: string;
|
|
title: string;
|
|
difficulty: string | null;
|
|
prepMins: number | null;
|
|
cookMins: number | null;
|
|
authorName: string | null;
|
|
coverPhotoKey: string | null;
|
|
};
|
|
|
|
/** Card for the "Favorites" grid — mirrors SearchResultCard's layout with a cover
|
|
* photo and an unfavorite toggle, since this view is specifically for managing
|
|
* (and removing from) your saved recipes, not just browsing them. */
|
|
export function FavoriteRecipeCard({
|
|
recipe,
|
|
className,
|
|
onUnfavorited,
|
|
}: {
|
|
recipe: FavoriteRecipe;
|
|
className?: string;
|
|
onUnfavorited?: (recipeId: string) => void;
|
|
}) {
|
|
const t = useTranslations("recipe");
|
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
|
const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard"
|
|
? t(`difficulty.${recipe.difficulty}`)
|
|
: recipe.difficulty;
|
|
|
|
return (
|
|
<Link
|
|
href={`/recipes/${recipe.id}`}
|
|
className={cn(
|
|
"group relative flex flex-col overflow-hidden rounded-xl border bg-card shadow-sm transition-shadow hover:shadow-md",
|
|
className
|
|
)}
|
|
>
|
|
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
|
|
{recipe.coverPhotoKey ? (
|
|
<Image
|
|
src={getPublicUrl(recipe.coverPhotoKey)}
|
|
unoptimized
|
|
alt={recipe.title}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
className="object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-3xl text-muted-foreground/40">
|
|
<ChefHat className="h-8 w-8" />
|
|
</div>
|
|
)}
|
|
<div className="absolute right-1 top-1" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
<FavoriteButton
|
|
recipeId={recipe.id}
|
|
initialFavorited
|
|
onToggle={(favorited) => { if (!favorited) onUnfavorited?.(recipe.id); }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-1 flex-col gap-1.5 p-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3 className="line-clamp-2 flex-1 text-sm font-semibold group-hover:text-primary">
|
|
{recipe.title}
|
|
</h3>
|
|
{recipe.difficulty && (
|
|
<Badge variant="secondary" className={cn("shrink-0 capitalize text-xs", DIFFICULTY_COLORS[recipe.difficulty])}>
|
|
{difficultyLabel}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
|
|
{totalMins > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
{t("total", { mins: totalMins })}
|
|
</span>
|
|
)}
|
|
{recipe.authorName && (
|
|
<span className="flex items-center gap-1 truncate">
|
|
<ChefHat className="h-3.5 w-3.5 shrink-0" />
|
|
{recipe.authorName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|