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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
127
frontend/src/pages/SharedView.tsx
Normal file
127
frontend/src/pages/SharedView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user