feat: redesign song detail page with artist stats and grid layout
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

This commit is contained in:
fullsizemalt 2025-12-31 10:05:53 -08:00
parent 1d8eb36034
commit 29cc0289d6
5 changed files with 149 additions and 67 deletions

View file

@ -110,6 +110,8 @@ def read_song(slug: str, session: Session = Depends(get_session)):
venue_name = "Unknown" venue_name = "Unknown"
venue_city = "" venue_city = ""
venue_state = "" venue_state = ""
artist_name = None
artist_slug = None
show_date = datetime.now() show_date = datetime.now()
show_slug = None show_slug = None
@ -120,6 +122,9 @@ def read_song(slug: str, session: Session = Depends(get_session)):
venue_name = p.show.venue.name venue_name = p.show.venue.name
venue_city = p.show.venue.city venue_city = p.show.venue.city
venue_state = p.show.venue.state venue_state = p.show.venue.state
if p.show.vertical:
artist_name = p.show.vertical.name
artist_slug = p.show.vertical.slug
r_stats = rating_stats.get(p.id, {"avg": 0.0, "count": 0}) r_stats = rating_stats.get(p.id, {"avg": 0.0, "count": 0})
@ -139,15 +144,22 @@ def read_song(slug: str, session: Session = Depends(get_session)):
venue_name=venue_name, venue_name=venue_name,
venue_city=venue_city, venue_city=venue_city,
venue_state=venue_state, venue_state=venue_state,
artist_name=artist_name,
artist_slug=artist_slug,
avg_rating=r_stats["avg"], avg_rating=r_stats["avg"],
total_reviews=r_stats["count"] total_reviews=r_stats["count"]
)) ))
# Calculate artist distribution
from collections import Counter
artist_dist = Counter(p.artist_name for p in perf_dtos if p.artist_name)
# Merge song data with stats # Merge song data with stats
song_with_stats = SongReadWithStats( song_with_stats = SongReadWithStats(
**song.model_dump(), **song.model_dump(),
**stats **stats
) )
song_with_stats.artist_distribution = artist_dist
song_with_stats.tags = tags song_with_stats.tags = tags
song_with_stats.performances = perf_dtos song_with_stats.performances = perf_dtos
return song_with_stats return song_with_stats

View file

@ -135,6 +135,8 @@ class PerformanceReadWithShow(PerformanceRead):
venue_name: str venue_name: str
venue_city: str venue_city: str
venue_state: Optional[str] = None venue_state: Optional[str] = None
artist_name: Optional[str] = None
artist_slug: Optional[str] = None
avg_rating: Optional[float] = 0.0 avg_rating: Optional[float] = 0.0
total_reviews: Optional[int] = 0 total_reviews: Optional[int] = 0
@ -143,6 +145,7 @@ class SongReadWithStats(SongRead):
gap: int gap: int
last_played: Optional[datetime] = None last_played: Optional[datetime] = None
set_breakdown: Dict[str, int] = {} set_breakdown: Dict[str, int] = {}
artist_distribution: Dict[str, int] = {}
performances: List[PerformanceReadWithShow] = [] performances: List[PerformanceReadWithShow] = []
class PerformanceDetailRead(PerformanceRead): class PerformanceDetailRead(PerformanceRead):

View file

@ -100,60 +100,95 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
</SocialWrapper> </SocialWrapper>
</div> </div>
<div className="grid gap-6 md:grid-cols-3"> <div className="grid grid-cols-1 md:grid-cols-12 gap-6">
<Card> {/* Left Sidebar: Stats & Charts */}
<CardHeader className="pb-2"> <div className="md:col-span-4 space-y-6">
<CardTitle className="text-sm font-medium text-muted-foreground">Times Played</CardTitle> {/* Basic Stats Grid - Compact */}
</CardHeader> <div className="grid grid-cols-2 gap-4">
<CardContent> <Card>
<div className="text-2xl font-bold flex items-center gap-2"> <CardHeader className="pb-2 p-4">
<PlayCircle className="h-5 w-5 text-primary" /> <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Times Played</CardTitle>
{song.times_played} </CardHeader>
</div> <CardContent className="p-4 pt-0">
</CardContent> <div className="text-2xl font-bold flex items-center gap-2">
</Card> <PlayCircle className="h-5 w-5 text-primary" />
<Card> {song.times_played}
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Gap (Shows)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold flex items-center gap-2">
<History className="h-5 w-5 text-primary" />
{song.gap}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Last Played</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold flex items-center gap-2">
<Calendar className="h-5 w-5 text-primary" />
{song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"}
</div>
</CardContent>
</Card>
</div>
{/* Set Breakdown */}
{song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Set Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-6">
{Object.entries(song.set_breakdown).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([set, count]) => (
<div key={set} className="flex flex-col items-center">
<span className="text-2xl font-bold">{count as number}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">{set}</span>
</div> </div>
))} </CardContent>
</div> </Card>
</CardContent> <Card>
</Card> <CardHeader className="pb-2 p-4">
)} <CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Gap</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold flex items-center gap-2">
<History className="h-5 w-5 text-primary" />
{song.gap}
</div>
</CardContent>
</Card>
<Card className="col-span-2">
<CardHeader className="pb-2 p-4">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Last Played</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-xl font-bold flex items-center gap-2">
<Calendar className="h-5 w-5 text-primary" />
{song.last_played ? new Date(song.last_played).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}) : "Never"}
</div>
</CardContent>
</Card>
</div>
{/* Most Played By */}
{song.artist_distribution && (
<MostPlayedByCard distribution={song.artist_distribution} />
)}
{/* Set Breakdown */}
{song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Set Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{Object.entries(song.set_breakdown).sort((a, b) => (b[1] as number) - (a[1] as number)).map(([set, count]) => (
<div key={set} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{set}</span>
<span className="font-bold">{count as number}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Content: Performance History */}
<div className="md:col-span-8 space-y-6">
<PerformanceList performances={song.performances} songTitle={song.title} />
{/* Song Evolution (moved to bottom) */}
<SongEvolutionChart performances={song.performances} />
<div className="grid gap-6 md:grid-cols-2">
<SocialWrapper type="comments">
<CommentSection entityType="song" entityId={song.id} />
</SocialWrapper>
<EntityReviews
entityType="song"
entityId={song.id}
entityName={song.title}
/>
</div>
</div>
</div>
{/* Heady Version Section */} {/* Heady Version Section */}
{headyVersions.length > 0 && ( {headyVersions.length > 0 && (
@ -282,20 +317,7 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
</Card> </Card>
)} )}
<SongEvolutionChart performances={song.performances || []} />
{/* Performance List Component (Handles Client Sorting) */}
<PerformanceList performances={song.performances || []} songTitle={song.title} />
<div className="grid gap-6 md:grid-cols-2">
<SocialWrapper type="comments">
<CommentSection entityType="song" entityId={song.id} />
</SocialWrapper>
<SocialWrapper type="reviews">
<EntityReviews entityType="song" entityId={song.id} />
</SocialWrapper>
</div>
</div> </div>
) )
} }

View file

@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Trophy } from "lucide-react"
interface MostPlayedByProps {
distribution: Record<string, number>
}
export function MostPlayedByCard({ distribution }: MostPlayedByProps) {
if (!distribution || Object.keys(distribution).length === 0) return null
// Sort entries by count descending
const sortedEntries = Object.entries(distribution).sort((a, b) => b[1] - a[1])
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Trophy className="h-4 w-4" />
Most Played By
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
{sortedEntries.map(([artist, count], index) => (
<div key={artist} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{artist}</span>
</div>
<span className="font-bold text-sm bg-secondary px-2 py-0.5 rounded-md min-w-[30px] text-center">
{count}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View file

@ -21,6 +21,8 @@ export interface Performance {
venue_name: string venue_name: string
venue_city: string venue_city: string
venue_state: string | null venue_state: string | null
artist_name?: string
artist_slug?: string
avg_rating: number avg_rating: number
total_reviews: number total_reviews: number
youtube_link?: string | null youtube_link?: string | null
@ -86,9 +88,14 @@ export function PerformanceList({ performances }: PerformanceListProps) {
> >
<div className="space-y-1 flex-1 min-w-0 pr-4"> <div className="space-y-1 flex-1 min-w-0 pr-4">
<div className="flex items-baseline gap-2 flex-wrap"> <div className="flex items-baseline gap-2 flex-wrap">
{perf.artist_name && (
<Badge variant="outline" className="border-primary/20 text-primary hover:bg-primary/10 transition-colors">
{perf.artist_name}
</Badge>
)}
<Link <Link
href={`/shows/${perf.show_slug}`} href={`/shows/${perf.show_slug}`}
className="font-medium hover:underline text-primary truncate" className="font-medium hover:underline text-foreground truncate"
> >
{new Date(perf.show_date).toLocaleDateString(undefined, { {new Date(perf.show_date).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',