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
This commit is contained in:
root
2026-03-31 18:20:43 -05:00
parent 3bab0b5911
commit 2e26aa03c4
5 changed files with 387 additions and 1 deletions

View File

@@ -11,8 +11,10 @@ import Discover from './pages/Discover'
import Recommendations from './pages/Recommendations'
import Billing from './pages/Billing'
import TasteProfilePage from './pages/TasteProfilePage'
import Analyze from './pages/Analyze'
import BandcampDiscover from './pages/BandcampDiscover'
import Admin from './pages/Admin'
import SharedView from './pages/SharedView'
function RootRedirect() {
const { user, loading } = useAuth()
@@ -84,6 +86,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/analyze"
element={
<ProtectedRoute>
<Layout>
<Analyze />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/bandcamp"
element={
@@ -124,6 +136,7 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route path="/shared/:recId/:token" element={<SharedView />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -1,5 +1,8 @@
import { Heart, ExternalLink, Music, ThumbsDown } from 'lucide-react'
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
@@ -10,6 +13,30 @@ interface Props {
}
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">
@@ -80,6 +107,27 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
</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}

View File

@@ -200,6 +200,23 @@ export const toggleSaveRecommendation = (id: string) =>
export const dislikeRecommendation = (id: string) =>
api.post<{ disliked: boolean }>(`/recommendations/${id}/dislike`).then((r) => r.data)
// Sharing
export const shareRecommendation = (id: string) =>
api.post<{ share_url: string }>(`/recommendations/${id}/share`).then((r) => r.data)
export const getSharedRecommendation = (recId: string, token: string) =>
api.get<{ title: string; artist: string; album: string | null; reason: string; youtube_url: string | null; image_url: string | null }>(`/recommendations/shared/${recId}/${token}`).then((r) => r.data)
// Analyze Song
export interface AnalyzeResponse {
analysis: string
qualities: string[]
recommendations: RecommendationItem[]
}
export const analyzeSong = (artist: string, title: string) =>
api.post<AnalyzeResponse>('/recommendations/analyze', { artist, title }).then((r) => r.data)
// YouTube Music Import
export interface YouTubeTrackResult {
title: string

View File

@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Music, ExternalLink } from 'lucide-react'
import { getSharedRecommendation } from '../lib/api'
interface SharedRec {
title: string
artist: string
album: string | null
reason: string
youtube_url: string | null
image_url: string | null
}
export default function SharedView() {
const { recId, token } = useParams<{ recId: string; token: string }>()
const [rec, setRec] = useState<SharedRec | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!recId || !token) return
setLoading(true)
getSharedRecommendation(recId, token)
.then(setRec)
.catch(() => setError('This share link is invalid or has expired.'))
.finally(() => setLoading(false))
}, [recId, token])
if (loading) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center">
<div className="w-12 h-12 border-4 border-[#7C3AED] border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !rec) {
return (
<div className="min-h-screen bg-[#FFF7ED] flex items-center justify-center p-6">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold text-[#1C1917] mb-2">Link Not Found</h1>
<p className="text-[#1C1917]/60 mb-6">{error || 'This recommendation could not be found.'}</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline"
>
Discover Music on Vynl
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-[#FFF7ED] flex flex-col">
{/* Header */}
<header className="py-6 px-6 text-center">
<Link to="/" className="inline-block no-underline">
<h1 className="text-2xl font-bold text-[#7C3AED] tracking-tight">vynl</h1>
</Link>
<p className="text-sm text-[#1C1917]/50 mt-1">A friend shared a discovery with you</p>
</header>
{/* Card */}
<main className="flex-1 flex items-start justify-center px-6 pb-12">
<div className="w-full max-w-lg bg-white rounded-2xl shadow-lg border border-[#7C3AED]/10 overflow-hidden">
{/* Album Art / Header */}
<div className="bg-gradient-to-br from-[#7C3AED] to-[#6D28D9] p-8 flex items-center gap-5">
<div className="w-24 h-24 rounded-xl bg-white/20 flex-shrink-0 flex items-center justify-center overflow-hidden">
{rec.image_url ? (
<img
src={rec.image_url}
alt={`${rec.title} cover`}
className="w-full h-full object-cover"
/>
) : (
<Music className="w-10 h-10 text-white/70" />
)}
</div>
<div className="min-w-0">
<h2 className="text-xl font-bold text-white truncate">{rec.title}</h2>
<p className="text-white/80 text-base truncate">{rec.artist}</p>
{rec.album && (
<p className="text-white/60 text-sm truncate mt-1">{rec.album}</p>
)}
</div>
</div>
{/* Reason */}
<div className="p-6">
<h3 className="text-sm font-semibold text-[#7C3AED] uppercase tracking-wide mb-2">
Why you might love this
</h3>
<p className="text-[#1C1917] leading-relaxed">{rec.reason}</p>
{/* YouTube link */}
{rec.youtube_url && (
<a
href={rec.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 bg-red-50 text-red-600 rounded-xl font-medium hover:bg-red-100 transition-colors no-underline text-sm"
>
<ExternalLink className="w-4 h-4" />
Listen on YouTube Music
</a>
)}
</div>
{/* CTA */}
<div className="border-t border-[#7C3AED]/10 p-6 text-center bg-[#FFF7ED]/50">
<p className="text-[#1C1917]/60 text-sm mb-3">
Want personalized music discoveries powered by AI?
</p>
<Link
to="/register"
className="inline-block px-6 py-3 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline"
>
Discover More on Vynl
</Link>
</div>
</div>
</main>
</div>
)
}