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>
)
}
|