All files / src/components RecipeCard.tsx

92.85% Statements 13/14
100% Branches 23/23
85.71% Functions 6/7
92.85% Lines 13/14

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>
  )
}
 
← Back to Dashboard