Files
vynl/frontend/src/components/RecommendationCard.tsx
root 2e26aa03c4 Add share discoveries feature with public share links
- Add single and batch share endpoints with signed URL tokens
- Add public view endpoints (no auth required) for shared recommendations
- Add share button with clipboard copy to RecommendationCard
- Create SharedView page with Vynl branding and registration CTA
- Add /shared/:recId/:token public route in App.tsx
- Add shareRecommendation and getSharedRecommendation API functions
2026-03-31 18:20:43 -05:00

147 lines
5.4 KiB
TypeScript

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Heart, ExternalLink, Music, ThumbsDown, Repeat, Share2, Check } from 'lucide-react'
import type { RecommendationItem } from '../lib/api'
import { shareRecommendation } from '../lib/api'
interface Props {
recommendation: RecommendationItem
onToggleSave: (id: string) => void
onDislike?: (id: string) => void
saving?: boolean
disliking?: boolean
}
export default function RecommendationCard({ recommendation, onToggleSave, onDislike, saving, disliking }: Props) {
const navigate = useNavigate()
const [sharing, setSharing] = useState(false)
const [shared, setShared] = useState(false)
const handleMoreLikeThis = () => {
const q = `find songs similar to ${recommendation.artist} - ${recommendation.title}`
navigate(`/discover?q=${encodeURIComponent(q)}`)
}
const handleShare = async () => {
if (sharing) return
setSharing(true)
try {
const { share_url } = await shareRecommendation(recommendation.id)
await navigator.clipboard.writeText(share_url)
setShared(true)
setTimeout(() => setShared(false), 2000)
} catch {
// Fallback: if clipboard fails, silently ignore
} finally {
setSharing(false)
}
}
return (
<div className="bg-white rounded-2xl border border-purple-100 shadow-sm hover:shadow-md transition-shadow overflow-hidden">
<div className="flex gap-4 p-5">
{/* Album Art */}
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-purple-200 to-purple-400 flex-shrink-0 flex items-center justify-center overflow-hidden">
{recommendation.image_url ? (
<img
src={recommendation.image_url}
alt={`${recommendation.title} cover`}
className="w-full h-full object-cover"
/>
) : (
<Music className="w-8 h-8 text-white/80" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-charcoal text-base truncate">
{recommendation.title}
</h3>
<p className="text-charcoal-muted text-sm truncate">
{recommendation.artist}
{recommendation.album && (
<span className="text-charcoal-muted/60"> &middot; {recommendation.album}</span>
)}
</p>
{/* Reason */}
<p className="text-sm text-charcoal-muted mt-2 line-clamp-2 leading-relaxed">
{recommendation.reason}
</p>
</div>
{/* Actions */}
<div className="flex flex-col items-center gap-2 flex-shrink-0">
<button
onClick={() => onToggleSave(recommendation.id)}
disabled={saving}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
recommendation.saved
? 'bg-red-50 text-red-500 hover:bg-red-100'
: 'bg-purple-50 text-purple-400 hover:bg-purple-100 hover:text-purple'
} ${saving ? 'opacity-50 cursor-not-allowed' : ''}`}
title={recommendation.saved ? 'Remove from saved' : 'Save recommendation'}
>
<Heart
className="w-5 h-5"
fill={recommendation.saved ? 'currentColor' : 'none'}
/>
</button>
{onDislike && (
<button
onClick={() => onDislike(recommendation.id)}
disabled={disliking}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
recommendation.disliked
? 'bg-charcoal-muted/20 text-charcoal'
: 'bg-gray-50 text-gray-400 hover:bg-gray-100 hover:text-charcoal-muted'
} ${disliking ? 'opacity-50 cursor-not-allowed' : ''}`}
title={recommendation.disliked ? 'Undo dislike' : 'Never recommend this type again'}
>
<ThumbsDown
className="w-4 h-4"
fill={recommendation.disliked ? 'currentColor' : 'none'}
/>
</button>
)}
<button
onClick={handleShare}
disabled={sharing}
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
shared
? 'bg-green-50 text-green-600'
: 'bg-purple-50 text-purple-600 hover:bg-purple-100 hover:text-purple-700'
} ${sharing ? 'opacity-50 cursor-not-allowed' : ''}`}
title={shared ? 'Link copied!' : 'Share'}
>
{shared ? <Check className="w-4 h-4" /> : <Share2 className="w-4 h-4" />}
</button>
<button
onClick={handleMoreLikeThis}
className="p-2 rounded-full bg-purple-50 text-purple-600 hover:bg-purple-100 hover:text-purple-700 transition-colors cursor-pointer border-none"
title="More like this"
>
<Repeat className="w-4 h-4" />
</button>
{recommendation.youtube_url && (
<a
href={recommendation.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-full bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
title="Watch on YouTube"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
</div>
)
}