feat: redesign band hub page and populate song stats
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

This commit is contained in:
fullsizemalt 2025-12-31 14:23:37 -08:00
parent f10f8ad465
commit 18b102558d
5 changed files with 263 additions and 164 deletions

View file

@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, col from sqlmodel import Session, select, col
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from database import get_session from database import get_session
from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review, Vertical from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review, Vertical, SongCanon
router = APIRouter(prefix="/search", tags=["search"]) router = APIRouter(prefix="/search", tags=["search"])
@ -18,13 +18,32 @@ def global_search(
q_str = f"%{q}%" q_str = f"%{q}%"
# Search Songs # Search Canonical Songs (The Hub)
songs = session.exec( canonical_songs = session.exec(
select(SongCanon)
.where(col(SongCanon.title).ilike(q_str))
.limit(limit)
).all()
# Search Songs (Artist Versions)
songs_raw = session.exec(
select(Song) select(Song)
.options(selectinload(Song.vertical))
.where(col(Song.title).ilike(q_str)) .where(col(Song.title).ilike(q_str))
.limit(limit) .limit(limit)
).all() ).all()
# Serialize songs with vertical info
songs = []
for s in songs_raw:
songs.append({
"id": s.id,
"title": s.title,
"slug": s.slug,
"original_artist": s.original_artist,
"vertical": {"name": s.vertical.name, "slug": s.vertical.slug} if s.vertical else None
})
# Search Venues # Search Venues
venues = session.exec( venues = session.exec(
select(Venue) select(Venue)
@ -118,6 +137,7 @@ def global_search(
).all() ).all()
return { return {
"canonical_songs": canonical_songs,
"songs": songs, "songs": songs,
"venues": venues, "venues": venues,
"tours": tours, "tours": tours,

View file

@ -37,14 +37,32 @@ def read_songs(
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset)) return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
if sort == "times_played": if sort == "times_played":
# Select both Song and count
query = select(Song, func.count(Performance.id).label("times_played"))
query = query.outerjoin(Performance).group_by(Song.id) query = query.outerjoin(Performance).group_by(Song.id)
# Calculate total count before pagination if vertical:
total = session.exec(select(func.count()).select_from(query.subquery())).one() query = query.where(Song.vertical_id == vertical_entity.id)
# Calculate total
total_query = select(func.count()).select_from(select(Song.id).where(Song.vertical_id == vertical_entity.id) if vertical else select(Song.id))
total = session.exec(total_query).one()
if sort == "times_played":
query = query.order_by(func.count(Performance.id).desc()) query = query.order_by(func.count(Performance.id).desc())
results = session.exec(query.offset(offset).limit(limit)).all()
# Map (Song, count) tuples to SongRead with times_played
songs = []
for song, count in results:
song_read = SongRead.model_validate(song)
song_read.times_played = count
songs.append(song_read)
else:
# Standard query
# Calculate total count before pagination
total = session.exec(select(func.count()).select_from(query.subquery())).one()
songs = session.exec(query.offset(offset).limit(limit)).all() songs = session.exec(query.offset(offset).limit(limit)).all()
return PaginatedResponse( return PaginatedResponse(

View file

@ -85,6 +85,7 @@ class SongRead(SongBase):
tags: List["TagRead"] = [] tags: List["TagRead"] = []
artist: Optional["ArtistRead"] = None artist: Optional["ArtistRead"] = None
vertical: Optional["VerticalSimple"] = None vertical: Optional["VerticalSimple"] = None
times_played: Optional[int] = 0

View file

@ -5,7 +5,15 @@ import Link from "next/link"
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight } from "lucide-react" import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { VideoGallery } from "@/components/videos/video-gallery" import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Show, Song, PaginatedResponse } from "@/types/models" import { Show, Song, PaginatedResponse } from "@/types/models"
interface Props { interface Props {
@ -20,7 +28,8 @@ export function generateStaticParams() {
async function getRecentShows(verticalSlug: string): Promise<Show[]> { async function getRecentShows(verticalSlug: string): Promise<Show[]> {
try { try {
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=8&status=past`, { // Fetch 10 recent shows
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=10&status=past`, {
next: { revalidate: 60 } next: { revalidate: 60 }
}) })
if (!res.ok) return [] if (!res.ok) return []
@ -33,7 +42,8 @@ async function getRecentShows(verticalSlug: string): Promise<Show[]> {
async function getTopSongs(verticalSlug: string): Promise<Song[]> { async function getTopSongs(verticalSlug: string): Promise<Song[]> {
try { try {
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=5&sort=times_played`, { // Fetch top 10 songs, assuming backend now populates times_played
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=10&sort=times_played`, {
next: { revalidate: 60 } next: { revalidate: 60 }
}) })
if (!res.ok) return [] if (!res.ok) return []
@ -44,6 +54,18 @@ async function getTopSongs(verticalSlug: string): Promise<Song[]> {
} }
} }
async function getVerticalStats(verticalSlug: string) {
try {
const res = await fetch(`${getApiUrl()}/bands/${verticalSlug}`, {
next: { revalidate: 300 }
})
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
export default async function VerticalPage({ params }: Props) { export default async function VerticalPage({ params }: Props) {
const { vertical: verticalSlug } = await params const { vertical: verticalSlug } = await params
const vertical = VERTICALS.find((v) => v.slug === verticalSlug) const vertical = VERTICALS.find((v) => v.slug === verticalSlug)
@ -52,166 +74,178 @@ export default async function VerticalPage({ params }: Props) {
notFound() notFound()
} }
const [recentShows, topSongs] = await Promise.all([ const [recentShows, topSongs, bandProfile] = await Promise.all([
getRecentShows(verticalSlug), getRecentShows(verticalSlug),
getTopSongs(verticalSlug) getTopSongs(verticalSlug),
getVerticalStats(verticalSlug)
]) ])
const navCards = [ const stats = bandProfile?.stats || {}
{ href: `/${verticalSlug}/shows`, icon: Calendar, title: "Shows", desc: "Browse the complete archive" },
{ href: `/${verticalSlug}/venues`, icon: Building, title: "Venues", desc: "Find your favorite spots" },
{ href: `/${verticalSlug}/songs`, icon: Music, title: "Songs", desc: "Explore the catalog" },
{ href: `/${verticalSlug}/performances`, icon: Trophy, title: "Top Performances", desc: "Highest rated jams" },
{ href: `/leaderboards?band=${verticalSlug}`, icon: Trophy, title: "Leaderboards", desc: "Top rated everything" },
{ href: `/${verticalSlug}/tours`, icon: Ticket, title: "Tours", desc: "Browse by tour" },
{ href: `/videos?band=${verticalSlug}`, icon: Video, title: "Videos", desc: "Watch full shows and songs" },
]
return ( return (
<div className="space-y-12 pb-12"> <div className="space-y-8 pb-12">
{/* Hero Section */} {/* Hero Section - Compact & Utilitarian */}
<section className="relative py-16 md:py-24 text-center space-y-6 bg-gradient-to-b from-primary/5 to-transparent"> <section className="bg-muted/30 border-b">
<h1 className="text-5xl md:text-6xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent"> <div className="container py-8 px-4">
{vertical.name} <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto px-4">
A comprehensive community-driven archive for {vertical.name} history.
<br className="hidden sm:block" />
Discover shows, share ratings, and explore the music together.
</p>
<div className="flex flex-wrap justify-center gap-4 pt-4">
<Link href={`/${verticalSlug}/performances`}>
<Button size="lg" className="gap-2">
<Trophy className="h-4 w-4" />
Top Performances
</Button>
</Link>
<Link href={`/${verticalSlug}/shows`}>
<Button size="lg" variant="outline" className="gap-2">
<Calendar className="h-4 w-4" />
Browse Shows
</Button>
</Link>
</div>
</section>
<div className="container max-w-6xl space-y-12 px-4">
{/* Recent Shows */}
{recentShows.length > 0 && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight">Recent Shows</h2>
<Link href={`/${verticalSlug}/shows`} className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1">
View all shows <ChevronRight className="h-4 w-4" />
</Link>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{recentShows.slice(0, 8).map((show) => (
<Link
key={show.id}
href={`/${verticalSlug}/shows/${show.slug}`}
className="group"
>
<Card className="h-full transition-all duration-200 hover:scale-[1.02] hover:shadow-lg hover:border-primary/50">
<CardContent className="p-4 space-y-2">
<div className="text-sm font-medium text-primary">
{new Date(show.date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</div>
<div className="font-semibold group-hover:text-primary transition-colors line-clamp-1">
{show.venue?.name || "Unknown Venue"}
</div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-3 w-3" />
{show.venue?.city}, {show.venue?.state || show.venue?.country}
</div>
{/* Tour is not in strict Show model yet, omitting for strictness or need to add to model */}
{/* {show.tour?.name && (
<div className="text-xs text-muted-foreground/80 pt-1">
{show.tour.name}
</div>
)} */}
</CardContent>
</Card>
</Link>
))}
</div>
</section>
)}
{/* Most Played Songs */}
{topSongs.length > 0 && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight">Most Played Songs</h2>
<Link href={`/${verticalSlug}/songs`} className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1">
View all songs <ChevronRight className="h-4 w-4" />
</Link>
</div>
<div className="space-y-2"> <div className="space-y-2">
{topSongs.slice(0, 5).map((song, index) => ( <h1 className="text-4xl font-bold tracking-tight">{vertical.name}</h1>
<Link <p className="text-muted-foreground max-w-2xl text-lg">
key={song.id} {vertical.description}
href={`/${verticalSlug}/songs/${song.slug}`} </p>
className="group"
>
<div className="flex items-center gap-4 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-all duration-200">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-bold text-primary">
{index + 1}
</div> </div>
<div className="flex-1 min-w-0">
<div className="font-medium group-hover:text-primary transition-colors truncate">
{song.title}
</div>
</div>
<div className="text-sm text-muted-foreground whitespace-nowrap">
{song.times_played || 0} performances
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Videos Section */} {/* High-Level Stats */}
<section className="space-y-4"> <div className="grid grid-cols-3 gap-8 text-center md:text-right">
<VideoGallery <div>
bandSlug={verticalSlug} <div className="text-3xl font-bold">{stats.total_shows || 0}</div>
limit={8} <div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Shows</div>
title="Recent Videos"
/>
</section>
{/* Navigation Cards */}
<section className="space-y-4">
<h2 className="text-2xl font-bold tracking-tight">Explore</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{navCards.map((card) => (
<Link key={card.href} href={card.href} className="group">
<Card className="h-full transition-all duration-200 hover:scale-[1.02] hover:shadow-lg hover:border-primary/50">
<CardContent className="p-6 flex items-start gap-4">
<div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
<card.icon className="h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold group-hover:text-primary transition-colors"> <div className="text-3xl font-bold">{stats.total_songs || 0}</div>
{card.title} <div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Songs</div>
</h3>
<p className="text-sm text-muted-foreground">
{card.desc}
</p>
</div> </div>
</CardContent> <div>
</Card> <div className="text-3xl font-bold">{stats.total_venues || 0}</div>
<div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Venues</div>
</div>
</div>
</div>
{/* Quick Actions Bar */}
<div className="flex flex-wrap gap-2 mt-8">
<Link href={`/${verticalSlug}/shows`}>
<Button variant="outline" size="sm" className="gap-2">
<Calendar className="h-4 w-4" /> Shows
</Button>
</Link> </Link>
))} <Link href={`/${verticalSlug}/songs`}>
<Button variant="outline" size="sm" className="gap-2">
<Music className="h-4 w-4" /> Songs
</Button>
</Link>
<Link href={`/${verticalSlug}/venues`}>
<Button variant="outline" size="sm" className="gap-2">
<MapPin className="h-4 w-4" /> Venues
</Button>
</Link>
<Link href={`/${verticalSlug}/performances`}>
<Button variant="outline" size="sm" className="gap-2">
<Trophy className="h-4 w-4" /> Top Rated
</Button>
</Link>
<Link href={`/videos?band=${verticalSlug}`}>
<Button variant="outline" size="sm" className="gap-2">
<Video className="h-4 w-4" /> Videos
</Button>
</Link>
</div>
</div> </div>
</section> </section>
<div className="container px-4 space-y-12">
<div className="grid gap-8 lg:grid-cols-2">
{/* Recent Shows Table */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight">Recent Shows</h2>
<Link href={`/${verticalSlug}/shows`}>
<Button variant="ghost" size="sm" className="gap-1">
View All <ChevronRight className="h-4 w-4" />
</Button>
</Link>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Venue</TableHead>
<TableHead className="text-right">Location</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentShows.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center h-24 text-muted-foreground">
No shows found
</TableCell>
</TableRow>
) : (
recentShows.map((show) => (
<TableRow key={show.id}>
<TableCell className="font-medium whitespace-nowrap">
<Link href={`/${verticalSlug}/shows/${show.slug}`} className="hover:underline text-primary">
{new Date(show.date).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
})}
</Link>
</TableCell>
<TableCell>
<span className="line-clamp-1">{show.venue?.name || "Unknown"}</span>
</TableCell>
<TableCell className="text-right text-muted-foreground">
{show.venue?.city}, {show.venue?.state}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</div>
{/* Most Played Songs Table */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight">Most Played Songs</h2>
<Link href={`/${verticalSlug}/songs`}>
<Button variant="ghost" size="sm" className="gap-1">
View Catalog <ChevronRight className="h-4 w-4" />
</Button>
</Link>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead className="text-right">Plays</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topSongs.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center h-24 text-muted-foreground">
No songs found
</TableCell>
</TableRow>
) : (
topSongs.map((song) => (
<TableRow key={song.id}>
<TableCell className="font-medium">
<Link href={`/${verticalSlug}/songs/${song.slug}`} className="hover:underline text-primary">
{song.title}
</Link>
{song.original_artist && (
<span className="ml-2 text-xs text-muted-foreground">
by {song.original_artist}
</span>
)}
</TableCell>
<TableCell className="text-right">
<Badge variant="secondary">{song.times_played || 0}</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
</div>
</div>
</div> </div>
</div> </div>
) )

View file

@ -136,17 +136,43 @@ export function SearchDialog() {
</CommandGroup> </CommandGroup>
)} )}
{results.songs?.length > 0 && ( {/* Canonical Songs (Hub Results) */}
<CommandGroup heading="Songs"> {results.canonical_songs && results.canonical_songs.length > 0 && (
{results.songs.map((song: any) => ( <CommandGroup heading="Canonical Songs (Cross-Band Hub)">
<CommandItem key={song.slug || song.id} onSelect={() => handleSelect(`/songs/${song.slug}`)}> {results.canonical_songs.map((song: any) => (
<Music className="mr-2 h-4 w-4" /> <CommandItem
<div className="flex flex-col"> key={`canon-${song.id}`}
value={`canon-${song.id}`}
onSelect={() => handleSelect(`/songs/${song.slug}`)}
>
<Library className="mr-2 h-4 w-4 text-primary" />
<span>{song.title}</span> <span>{song.title}</span>
{song.original_artist && ( {song.original_artist && (
<span className="text-[10px] text-muted-foreground">Original by {song.original_artist}</span> <span className="ml-2 text-xs text-muted-foreground">
(Orig. {song.original_artist})
</span>
)}
</CommandItem>
))}
</CommandGroup>
)}
{/* Artist Specific Songs */}
{results.songs && results.songs.length > 0 && (
<CommandGroup heading="Artist Versions">
{results.songs.map((song: any) => (
<CommandItem
key={song.id}
value={song.title}
onSelect={() => handleSelect(`/${song.vertical?.slug || 'all'}/songs/${song.slug}`)}
>
<Music className="mr-2 h-4 w-4 text-muted-foreground" />
<span>{song.title}</span>
{song.vertical && (
<Badge variant="secondary" className="ml-2 text-[10px] h-5 px-1.5 font-normal">
{song.vertical.name}
</Badge>
)} )}
</div>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>