Files
Epicure/apps/web/components/recipe/recipe-card.tsx
T
Arnaud af92b8cc71 feat: auto-classify AI recipes as dish/drink (v0.33.0)
generate, generate-from-idea, URL import, and photo import all now
have the AI set recipeType directly (defaulting to "dish" whenever
ambiguous, per instruction), with cookMins force-cleared for drinks
as a backstop in case the model doesn't follow the prompt guidance.
1-serving-by-default for unspecified drink quantities is left to the
model's own judgment of the request text, since there's no reliable
way to detect "was a serving count stated" without re-parsing intent.

Drink recipes get a distinct default icon (no cover photo case), and
Explore gains the same dish/drink Type filter My Recipes already had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 15:17:33 +02:00

99 lines
3.5 KiB
TypeScript

"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Clock, Users, Lock, Globe, Link2, ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { getPublicUrl } from "@/lib/storage";
type Recipe = {
id: string;
title: string;
description: string | null;
baseServings: number;
prepMins: number | null;
cookMins: number | null;
difficulty: "easy" | "medium" | "hard" | null;
visibility: "private" | "unlisted" | "public";
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
sourceUrl?: string | null;
recipeType?: "dish" | "drink";
};
const VISIBILITY_ICON = {
private: Lock,
unlisted: Link2,
public: Globe,
};
const DIFFICULTY_COLOR = {
easy: "default",
medium: "secondary",
hard: "destructive",
} as const;
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const t = useTranslations("recipe");
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
<Link href={`/recipes/${recipe.id}`}>
<Card className="group overflow-hidden hover:shadow-md transition-shadow h-full flex flex-col">
{cover ? (
<div className="relative aspect-video overflow-hidden bg-muted">
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
) : (
<div className="aspect-video bg-gradient-to-br from-muted to-muted/50 flex items-center justify-center text-4xl">
{recipe.recipeType === "drink" ? "🍹" : "🍽️"}
</div>
)}
<CardHeader className="pb-2">
<h3 className="font-semibold leading-tight line-clamp-2 group-hover:text-primary transition-colors">
{recipe.title}
</h3>
{recipe.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{recipe.description}</p>
)}
</CardHeader>
<CardContent className="pb-2 flex-1" />
<CardFooter className="pt-4 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{recipe.baseServings}
</span>
{totalMins > 0 && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{t("total", { mins: totalMins })}
</span>
)}
{recipe.difficulty && (
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4">
{t(`difficulty.${recipe.difficulty}`)}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{recipe.sourceUrl && <ExternalLink className="h-3 w-3" />}
<VisibilityIcon className="h-3 w-3" />
</div>
</CardFooter>
</Card>
</Link>
);
}