- 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
147 lines
5.4 KiB
TypeScript
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"> · {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>
|
|
)
|
|
}
|