Add public shareable taste profiles

Users can generate a share link for their taste profile via the
"Share My Taste" button. The link opens a public page showing
listening personality, genre breakdown, audio features, and top
artists with a CTA to register. Token-based URL prevents enumeration.
This commit is contained in:
root
2026-03-31 20:51:12 -05:00
parent db2767bfda
commit 0ee8f9a144
5 changed files with 443 additions and 11 deletions

View File

@@ -1,7 +1,10 @@
from fastapi import APIRouter, Depends import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.models.user import User from app.models.user import User
@@ -198,3 +201,164 @@ async def get_taste_profile(
"track_count": len(all_tracks), "track_count": len(all_tracks),
"playlist_count": len(playlists), "playlist_count": len(playlists),
} }
async def _build_taste_profile(user_id: int, db: AsyncSession) -> dict:
"""Build a taste profile dict for the given user_id (shared logic)."""
result = await db.execute(
select(Playlist).where(Playlist.user_id == user_id)
)
playlists = list(result.scalars().all())
all_tracks = []
for p in playlists:
result = await db.execute(select(Track).where(Track.playlist_id == p.id))
all_tracks.extend(result.scalars().all())
if not all_tracks:
return {
"genre_breakdown": [],
"audio_features": {
"energy": 0,
"danceability": 0,
"valence": 0,
"acousticness": 0,
"avg_tempo": 0,
},
"personality": {
"label": "New Listener",
"description": "Import some playlists to discover your listening personality!",
"icon": "music",
},
"top_artists": [],
"track_count": 0,
"playlist_count": len(playlists),
}
# Genre breakdown
genres_count: dict[str, int] = {}
for t in all_tracks:
if t.genres:
for g in t.genres:
genres_count[g] = genres_count.get(g, 0) + 1
total_genre_mentions = sum(genres_count.values()) or 1
top_genres = sorted(genres_count.items(), key=lambda x: x[1], reverse=True)[:10]
genre_breakdown = [
{"name": g, "percentage": round((c / total_genre_mentions) * 100, 1)}
for g, c in top_genres
]
# Audio features averages + variance
energies = []
danceabilities = []
valences = []
acousticnesses = []
tempos = []
for t in all_tracks:
if t.energy is not None:
energies.append(t.energy)
if t.danceability is not None:
danceabilities.append(t.danceability)
if t.valence is not None:
valences.append(t.valence)
if t.acousticness is not None:
acousticnesses.append(t.acousticness)
if t.tempo is not None:
tempos.append(t.tempo)
def avg(lst: list[float]) -> float:
return round(sum(lst) / len(lst), 3) if lst else 0
def variance(lst: list[float]) -> float:
if len(lst) < 2:
return 0
m = sum(lst) / len(lst)
return sum((x - m) ** 2 for x in lst) / len(lst)
avg_energy = avg(energies)
avg_danceability = avg(danceabilities)
avg_valence = avg(valences)
avg_acousticness = avg(acousticnesses)
avg_tempo = round(avg(tempos), 0)
# Personality
personality = _determine_personality(
genre_count=len(genres_count),
avg_energy=avg_energy,
avg_valence=avg_valence,
avg_acousticness=avg_acousticness,
energy_variance=variance(energies),
valence_variance=variance(valences),
)
# Top artists
artist_count: dict[str, int] = {}
for t in all_tracks:
artist_count[t.artist] = artist_count.get(t.artist, 0) + 1
top_artists_sorted = sorted(artist_count.items(), key=lambda x: x[1], reverse=True)[:8]
artist_genres: dict[str, str] = {}
for t in all_tracks:
if t.artist in dict(top_artists_sorted) and t.genres and t.artist not in artist_genres:
artist_genres[t.artist] = t.genres[0]
top_artists = [
{
"name": name,
"track_count": count,
"genre": artist_genres.get(name, ""),
}
for name, count in top_artists_sorted
]
return {
"genre_breakdown": genre_breakdown,
"audio_features": {
"energy": round(avg_energy * 100),
"danceability": round(avg_danceability * 100),
"valence": round(avg_valence * 100),
"acousticness": round(avg_acousticness * 100),
"avg_tempo": avg_tempo,
},
"personality": personality,
"top_artists": top_artists,
"track_count": len(all_tracks),
"playlist_count": len(playlists),
}
def _generate_profile_token(user_id: int) -> str:
"""Generate a deterministic share token for a user's profile."""
return hashlib.sha256(
f"profile:{user_id}:{settings.SECRET_KEY}".encode()
).hexdigest()[:16]
@router.get("/share-link")
async def get_profile_share_link(user: User = Depends(get_current_user)):
"""Generate a share link for the user's taste profile."""
token = _generate_profile_token(user.id)
return {"share_url": f"{settings.FRONTEND_URL}/taste/{user.id}/{token}"}
@router.get("/public/{user_id}/{token}")
async def get_public_profile(
user_id: int,
token: str,
db: AsyncSession = Depends(get_db),
):
"""Public taste profile — no auth required."""
expected = _generate_profile_token(user_id)
if token != expected:
raise HTTPException(status_code=404, detail="Invalid profile link")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="Profile not found")
profile = await _build_taste_profile(user_id, db)
profile["name"] = user.name.split()[0] # First name only for privacy
return profile

View File

@@ -22,6 +22,7 @@ import Compatibility from './pages/Compatibility'
import CrateDigger from './pages/CrateDigger' import CrateDigger from './pages/CrateDigger'
import RabbitHole from './pages/RabbitHole' import RabbitHole from './pages/RabbitHole'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import PublicProfile from './pages/PublicProfile'
function RootRedirect() { function RootRedirect() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
@@ -214,6 +215,7 @@ function AppRoutes() {
} }
/> />
<Route path="/shared/:recId/:token" element={<SharedView />} /> <Route path="/shared/:recId/:token" element={<SharedView />} />
<Route path="/taste/:userId/:token" element={<PublicProfile />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
) )

View File

@@ -448,6 +448,7 @@ export interface AdminStats {
users: { total: number; pro: number; free: number } users: { total: number; pro: number; free: number }
playlists: { total: number; total_tracks: number } playlists: { total: number; total_tracks: number }
recommendations: { total: number; today: number; this_week: number; this_month: number; saved: number; disliked: number } recommendations: { total: number; today: number; this_week: number; this_month: number; saved: number; disliked: number }
api_costs: { total_estimated: number; today_estimated: number; total_input_tokens: number; total_output_tokens: number }
user_breakdown: { id: number; name: string; email: string; is_pro: boolean; created_at: string; recommendation_count: number }[] user_breakdown: { id: number; name: string; email: string; is_pro: boolean; created_at: string; recommendation_count: number }[]
} }

View File

@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User } from 'lucide-react'
import { getPublicProfile, type TasteProfileResponse } from '../lib/api'
const personalityIcons: Record<string, typeof Zap> = {
zap: Zap,
cloud: CloudSun,
heart: Heart,
layers: Layers,
globe: Globe,
drama: Drama,
music: Music2,
}
const genreBarColors = [
'bg-[#7C3AED]',
'bg-[#8B5CF6]',
'bg-[#9F6FFB]',
'bg-[#A78BFA]',
'bg-[#B49BFC]',
'bg-[#C4ABFD]',
'bg-[#D3BCFE]',
'bg-[#DDD6FE]',
'bg-[#E8DFFE]',
'bg-[#F0EAFF]',
]
type PublicProfileData = TasteProfileResponse & { name: string }
function possessive(name: string): string {
return name.endsWith('s') ? `${name}'` : `${name}'s`
}
export default function PublicProfile() {
const { userId, token } = useParams<{ userId: string; token: string }>()
const [profile, setProfile] = useState<PublicProfileData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!userId || !token) return
setLoading(true)
getPublicProfile(userId, token)
.then(setProfile)
.catch(() => setError('This profile link is invalid or has expired.'))
.finally(() => setLoading(false))
}, [userId, 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 || !profile) {
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">Profile Not Found</h1>
<p className="text-[#1C1917]/60 mb-6">{error || 'This taste profile could not be found.'}</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 Your Taste on Vynl
</Link>
</div>
</div>
)
}
const PersonalityIcon = personalityIcons[profile.personality.icon] || Music2
const maxGenrePercent = profile.genre_breakdown.length > 0
? Math.max(...profile.genre_breakdown.map((g) => g.percentage))
: 100
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>
</header>
{/* Content */}
<main className="flex-1 px-4 pb-12 max-w-3xl mx-auto w-full space-y-6">
{/* Title */}
<div className="text-center">
<h2 className="text-3xl font-bold text-[#1C1917] flex items-center justify-center gap-3">
<Fingerprint className="w-8 h-8 text-[#7C3AED]" />
{possessive(profile.name)} Music DNA
</h2>
<p className="text-[#1C1917]/50 mt-1 text-sm">
Built from {profile.track_count} tracks across {profile.playlist_count} playlists
</p>
</div>
{/* Listening Personality */}
<div className="bg-gradient-to-br from-[#7C3AED] to-[#5B21B6] rounded-2xl p-8 text-white">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-2xl bg-white/15 flex items-center justify-center flex-shrink-0">
<PersonalityIcon className="w-7 h-7 text-white" />
</div>
<div>
<p className="text-purple-200 text-xs font-semibold uppercase tracking-wider mb-1">
Listening Personality
</p>
<h3 className="text-2xl font-bold mb-2">{profile.personality.label}</h3>
<p className="text-purple-100 text-sm leading-relaxed">
{profile.personality.description}
</p>
</div>
</div>
</div>
{/* Genre DNA */}
{profile.genre_breakdown.length > 0 && (
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-6">
<h3 className="text-lg font-semibold text-[#1C1917] mb-5">Genre DNA</h3>
<div className="space-y-3">
{profile.genre_breakdown.map((genre, i) => (
<div key={genre.name} className="flex items-center gap-3">
<span className="text-sm text-[#1C1917] w-28 truncate text-right flex-shrink-0 font-medium">
{genre.name}
</span>
<div className="flex-1 bg-[#F5F3FF] rounded-full h-6 overflow-hidden">
<div
className={`h-full rounded-full ${genreBarColors[i] || genreBarColors[genreBarColors.length - 1]} transition-all duration-700 ease-out flex items-center justify-end pr-2`}
style={{ width: `${(genre.percentage / maxGenrePercent) * 100}%`, minWidth: '2rem' }}
>
<span className="text-xs font-semibold text-white drop-shadow-sm">
{genre.percentage}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Audio Features */}
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-6">
<h3 className="text-lg font-semibold text-[#1C1917] mb-5">Audio Features</h3>
<div className="space-y-4">
<PublicAudioMeter label="Energy" value={profile.audio_features.energy} />
<PublicAudioMeter label="Danceability" value={profile.audio_features.danceability} />
<PublicAudioMeter label="Mood / Valence" value={profile.audio_features.valence} />
<PublicAudioMeter label="Acousticness" value={profile.audio_features.acousticness} />
<div className="flex items-center gap-3 pt-2 border-t border-[#7C3AED]/10">
<span className="text-sm text-[#1C1917] w-28 text-right flex-shrink-0 font-medium">
Avg Tempo
</span>
<div className="flex-1 flex items-center gap-2">
<span className="text-2xl font-bold text-[#7C3AED]">{profile.audio_features.avg_tempo}</span>
<span className="text-sm text-[#1C1917]/50">BPM</span>
</div>
</div>
</div>
</div>
{/* Top Artists */}
{profile.top_artists.length > 0 && (
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-6">
<h3 className="text-lg font-semibold text-[#1C1917] mb-5">Artists That Define Them</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{profile.top_artists.map((artist) => (
<div
key={artist.name}
className="flex items-center gap-3 p-3 rounded-xl bg-[#FFF7ED]/60 border border-[#7C3AED]/5"
>
<div className="w-10 h-10 rounded-full bg-[#F5F3FF] flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-[#7C3AED]" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-[#1C1917] truncate">{artist.name}</p>
<p className="text-xs text-[#1C1917]/50">
{artist.track_count} track{artist.track_count !== 1 ? 's' : ''}
{artist.genre ? ` · ${artist.genre}` : ''}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* CTA */}
<div className="bg-white rounded-2xl border border-[#7C3AED]/10 p-8 text-center">
<p className="text-[#1C1917]/60 text-sm mb-4">
Want to discover your own music DNA?
</p>
<Link
to="/register"
className="inline-block px-8 py-3.5 bg-[#7C3AED] text-white rounded-xl font-semibold hover:bg-[#6D28D9] transition-colors no-underline text-base"
>
Discover Your Taste on Vynl
</Link>
</div>
</main>
</div>
)
}
function PublicAudioMeter({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-[#1C1917] w-28 text-right flex-shrink-0 font-medium">
{label}
</span>
<div className="flex-1 bg-[#F5F3FF] rounded-full h-5 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-[#7C3AED] to-[#A78BFA] transition-all duration-700 ease-out flex items-center justify-end pr-2"
style={{ width: `${value}%`, minWidth: value > 0 ? '1.5rem' : '0' }}
>
{value > 10 && (
<span className="text-xs font-semibold text-white drop-shadow-sm">
{value}%
</span>
)}
</div>
</div>
{value <= 10 && (
<span className="text-xs font-medium text-[#1C1917]/50 w-10">{value}%</span>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User } from 'lucide-react' import { Loader2, Fingerprint, Zap, Music2, CloudSun, Heart, Layers, Globe, Drama, User, Share2, Check, Copy } from 'lucide-react'
import { getTasteProfile, type TasteProfileResponse } from '../lib/api' import { getTasteProfile, getProfileShareLink, type TasteProfileResponse } from '../lib/api'
const personalityIcons: Record<string, typeof Zap> = { const personalityIcons: Record<string, typeof Zap> = {
zap: Zap, zap: Zap,
@@ -28,6 +28,8 @@ const genreBarColors = [
export default function TasteProfilePage() { export default function TasteProfilePage() {
const [profile, setProfile] = useState<TasteProfileResponse | null>(null) const [profile, setProfile] = useState<TasteProfileResponse | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [sharing, setSharing] = useState(false)
useEffect(() => { useEffect(() => {
getTasteProfile() getTasteProfile()
@@ -60,6 +62,7 @@ export default function TasteProfilePage() {
return ( return (
<div className="space-y-8 max-w-3xl mx-auto"> <div className="space-y-8 max-w-3xl mx-auto">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold text-charcoal flex items-center gap-3"> <h1 className="text-3xl font-bold text-charcoal flex items-center gap-3">
<Fingerprint className="w-8 h-8 text-purple" /> <Fingerprint className="w-8 h-8 text-purple" />
@@ -69,6 +72,36 @@ export default function TasteProfilePage() {
Your musical identity, decoded from {profile.track_count} tracks across {profile.playlist_count} playlists Your musical identity, decoded from {profile.track_count} tracks across {profile.playlist_count} playlists
</p> </p>
</div> </div>
<button
onClick={async () => {
setSharing(true)
try {
const { share_url } = await getProfileShareLink()
await navigator.clipboard.writeText(share_url)
setCopied(true)
setTimeout(() => setCopied(false), 2500)
} catch {
// fallback: ignore
} finally {
setSharing(false)
}
}}
disabled={sharing}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-purple text-white font-semibold text-sm hover:bg-purple-dark transition-colors flex-shrink-0 disabled:opacity-60"
>
{copied ? (
<>
<Check className="w-4 h-4" />
Copied!
</>
) : (
<>
<Share2 className="w-4 h-4" />
Share My Taste
</>
)}
</button>
</div>
{/* Listening Personality */} {/* Listening Personality */}
<div className="bg-gradient-to-br from-purple to-purple-dark rounded-2xl p-8 text-white"> <div className="bg-gradient-to-br from-purple to-purple-dark rounded-2xl p-8 text-white">