All files / src/components RecipeCard.tsx

96.55% Statements 28/29
100% Branches 37/37
92.3% Functions 12/13
96.15% Lines 25/26

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 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181                                      23x   23x 22x                     1x                     4x                                     10x   10x 10x 4x 4x     10x 6x 6x 2x   4x               10x                                     23x                                                   23x   23x       23x 2x 2x     23x           1x             2x                                                      
import { useState, useCallback, useEffect } from 'react'
import { Star, Clock, Heart, Trash2 } 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
  onDelete?: (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 DeleteButton({ onDelete }: { onDelete: () => void }) {
  const [confirming, setConfirming] = useState(false)
 
  useEffect(() => {
    if (!confirming) return
    const t = setTimeout(() => setConfirming(false), 2500)
    return () => clearTimeout(t)
  }, [confirming])
 
  const handleClick = (e: React.MouseEvent) => {
    e.stopPropagation()
    if (confirming) {
      onDelete()
    } else {
      setConfirming(true)
    }
  }
 
  // The two-tap-to-confirm pattern needs an in-button text cue when primed —
  // the title attribute is desktop-only (no hover on touch), so without
  // visible text mobile users see the icon turn red and assume the action
  // happened. Show "Tap to confirm" when primed.
  return (
    <button
      onClick={handleClick}
      className={cn(
        'absolute right-2 top-2 flex items-center gap-1.5 rounded-full backdrop-blur-sm transition-colors',
        confirming
          ? 'bg-destructive px-3 py-2 text-destructive-foreground hover:bg-destructive/90'
          : 'bg-background/80 p-2 text-muted-foreground hover:bg-background hover:text-destructive'
      )}
      aria-label={confirming ? 'Tap again to confirm delete' : 'Delete recipe'}
      title={confirming ? 'Tap again to confirm' : 'Delete recipe'}
    >
      <Trash2 className="h-5 w-5" />
      {confirming && <span className="text-xs font-medium">Tap to confirm</span>}
    </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,
  onDelete,
  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} />
 
        {onDelete && (
          <DeleteButton onDelete={() => onDelete(recipe)} />
        )}
 
        {onFavoriteToggle && (
          <FavoriteButton isFavorite={isFavorite} onClick={handleFavoriteClick} />
        )}
 
        {recipe.is_remix && (
          <div className={cn(
            'absolute top-2 rounded-full bg-primary/90 px-2 py-0.5 text-xs text-primary-foreground backdrop-blur-sm',
            'left-2'
          )}>
            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