Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | 17x 17x 16x 1x 4x 17x 17x 17x 17x 2x 2x 17x 1x | import { useState, useCallback } from 'react'
import { Star, Clock, Heart } from 'lucide-react'
import type { Recipe } from '../api/client'
import { cn } from '../lib/utils'
import { formatTime } from '../lib/formatting'
interface RecipeCardProps {
recipe: Recipe
isFavorite?: boolean
onFavoriteToggle?: (recipe: Recipe) => void
onClick?: (recipe: Recipe) => void
}
function RecipeImage({ recipe, imgError, onError }: {
recipe: Recipe
imgError: boolean
onError: () => void
}) {
const imageUrl = recipe.image || recipe.image_url
if (imageUrl && !imgError) {
return (
<img
src={imageUrl}
alt={recipe.title}
loading="lazy"
onError={onError}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
)
}
return (
<div className="flex h-full w-full items-center justify-center bg-muted px-3 text-center text-sm font-medium text-muted-foreground">
{recipe.title}
</div>
)
}
function FavoriteButton({ isFavorite, onClick }: {
isFavorite: boolean
onClick: (e: React.MouseEvent) => void
}) {
return (
<button
onClick={onClick}
className={cn(
'absolute right-2 top-2 rounded-full bg-background/80 p-2 backdrop-blur-sm transition-colors',
isFavorite
? 'text-accent hover:bg-background'
: 'text-muted-foreground hover:bg-background hover:text-accent'
)}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={cn('h-5 w-5', isFavorite && 'fill-current')}
/>
</button>
)
}
function RecipeMeta({ recipe }: { recipe: Recipe }) {
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="truncate">{recipe.host}</span>
{recipe.total_time && (
<span className="flex shrink-0 items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(recipe.total_time)}
</span>
)}
{recipe.rating && (
<span className="flex shrink-0 items-center gap-1">
<Star className="h-3 w-3 fill-star text-star" />
{recipe.rating.toFixed(1)}
</span>
)}
</div>
)
}
export default function RecipeCard({
recipe,
isFavorite = false,
onFavoriteToggle,
onClick,
}: RecipeCardProps) {
const [imgError, setImgError] = useState(false)
const handleImgError = useCallback(() => {
setImgError(true)
}, [])
const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation()
onFavoriteToggle?.(recipe)
}
return (
<div
className={cn(
'group relative overflow-hidden rounded-lg bg-card shadow-sm transition-all hover:shadow-md',
onClick && 'cursor-pointer'
)}
onClick={() => onClick?.(recipe)}
>
{/* Image */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
<RecipeImage recipe={recipe} imgError={imgError} onError={handleImgError} />
{onFavoriteToggle && (
<FavoriteButton isFavorite={isFavorite} onClick={handleFavoriteClick} />
)}
{recipe.is_remix && (
<div className="absolute left-2 top-2 rounded-full bg-primary/90 px-2 py-0.5 text-xs text-primary-foreground backdrop-blur-sm">
Remix
</div>
)}
</div>
{/* Content */}
<div className="p-3">
<h3 className="mb-1 line-clamp-2 text-sm font-medium text-card-foreground">
{recipe.title}
</h3>
<RecipeMeta recipe={recipe} />
</div>
</div>
)
}
|