feat: redesign song detail page with artist stats and grid layout
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
This commit is contained in:
parent
1d8eb36034
commit
29cc0289d6
5 changed files with 149 additions and 67 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -100,12 +100,16 @@ 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">
|
||||||
|
{/* Left Sidebar: Stats & Charts */}
|
||||||
|
<div className="md:col-span-4 space-y-6">
|
||||||
|
{/* Basic Stats Grid - Compact */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2 p-4">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Times Played</CardTitle>
|
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Times Played</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0">
|
||||||
<div className="text-2xl font-bold flex items-center gap-2">
|
<div className="text-2xl font-bold flex items-center gap-2">
|
||||||
<PlayCircle className="h-5 w-5 text-primary" />
|
<PlayCircle className="h-5 w-5 text-primary" />
|
||||||
{song.times_played}
|
{song.times_played}
|
||||||
|
|
@ -113,29 +117,39 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2 p-4">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Gap (Shows)</CardTitle>
|
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Gap</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0">
|
||||||
<div className="text-2xl font-bold flex items-center gap-2">
|
<div className="text-2xl font-bold flex items-center gap-2">
|
||||||
<History className="h-5 w-5 text-primary" />
|
<History className="h-5 w-5 text-primary" />
|
||||||
{song.gap}
|
{song.gap}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className="col-span-2">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2 p-4">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Last Played</CardTitle>
|
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Last Played</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0">
|
||||||
<div className="text-2xl font-bold flex items-center gap-2">
|
<div className="text-xl font-bold flex items-center gap-2">
|
||||||
<Calendar className="h-5 w-5 text-primary" />
|
<Calendar className="h-5 w-5 text-primary" />
|
||||||
{song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"}
|
{song.last_played ? new Date(song.last_played).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
}) : "Never"}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Most Played By */}
|
||||||
|
{song.artist_distribution && (
|
||||||
|
<MostPlayedByCard distribution={song.artist_distribution} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Set Breakdown */}
|
{/* Set Breakdown */}
|
||||||
{song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && (
|
{song.set_breakdown && Object.keys(song.set_breakdown).length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -143,17 +157,38 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Set Distribution</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Set Distribution</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-6">
|
<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]) => (
|
{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">
|
<div key={set} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-2xl font-bold">{count as number}</span>
|
<span className="text-muted-foreground">{set}</span>
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">{set}</span>
|
<span className="font-bold">{count as number}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
frontend/components/songs/most-played-by-card.tsx
Normal file
38
frontend/components/songs/most-played-by-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue