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_city = ""
|
||||
venue_state = ""
|
||||
artist_name = None
|
||||
artist_slug = None
|
||||
show_date = datetime.now()
|
||||
show_slug = None
|
||||
|
||||
|
|
@ -120,6 +122,9 @@ def read_song(slug: str, session: Session = Depends(get_session)):
|
|||
venue_name = p.show.venue.name
|
||||
venue_city = p.show.venue.city
|
||||
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})
|
||||
|
||||
|
|
@ -139,15 +144,22 @@ def read_song(slug: str, session: Session = Depends(get_session)):
|
|||
venue_name=venue_name,
|
||||
venue_city=venue_city,
|
||||
venue_state=venue_state,
|
||||
artist_name=artist_name,
|
||||
artist_slug=artist_slug,
|
||||
avg_rating=r_stats["avg"],
|
||||
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
|
||||
song_with_stats = SongReadWithStats(
|
||||
**song.model_dump(),
|
||||
**stats
|
||||
)
|
||||
song_with_stats.artist_distribution = artist_dist
|
||||
song_with_stats.tags = tags
|
||||
song_with_stats.performances = perf_dtos
|
||||
return song_with_stats
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ class PerformanceReadWithShow(PerformanceRead):
|
|||
venue_name: str
|
||||
venue_city: str
|
||||
venue_state: Optional[str] = None
|
||||
artist_name: Optional[str] = None
|
||||
artist_slug: Optional[str] = None
|
||||
avg_rating: Optional[float] = 0.0
|
||||
total_reviews: Optional[int] = 0
|
||||
|
||||
|
|
@ -143,6 +145,7 @@ class SongReadWithStats(SongRead):
|
|||
gap: int
|
||||
last_played: Optional[datetime] = None
|
||||
set_breakdown: Dict[str, int] = {}
|
||||
artist_distribution: Dict[str, int] = {}
|
||||
performances: List[PerformanceReadWithShow] = []
|
||||
|
||||
class PerformanceDetailRead(PerformanceRead):
|
||||
|
|
|
|||
|
|
@ -100,12 +100,16 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
|||
</SocialWrapper>
|
||||
</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>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Times Played</CardTitle>
|
||||
<CardHeader className="pb-2 p-4">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Times Played</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<PlayCircle className="h-5 w-5 text-primary" />
|
||||
{song.times_played}
|
||||
|
|
@ -113,29 +117,39 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
|||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Gap (Shows)</CardTitle>
|
||||
<CardHeader className="pb-2 p-4">
|
||||
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Gap</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Last Played</CardTitle>
|
||||
<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>
|
||||
<div className="text-2xl font-bold flex items-center gap-2">
|
||||
<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() : "Never"}
|
||||
{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>
|
||||
|
|
@ -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>
|
||||
</CardHeader>
|
||||
<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]) => (
|
||||
<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 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 */}
|
||||
{headyVersions.length > 0 && (
|
||||
|
|
@ -282,20 +317,7 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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_city: string
|
||||
venue_state: string | null
|
||||
artist_name?: string
|
||||
artist_slug?: string
|
||||
avg_rating: number
|
||||
total_reviews: number
|
||||
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="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
|
||||
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, {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue