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,15 +37,33 @@ 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())
songs = session.exec(query.offset(offset).limit(limit)).all() 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()
return PaginatedResponse( return PaginatedResponse(
data=songs, data=songs,

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> <div className="space-y-2">
<p className="text-xl text-muted-foreground max-w-2xl mx-auto px-4"> <h1 className="text-4xl font-bold tracking-tight">{vertical.name}</h1>
A comprehensive community-driven archive for {vertical.name} history. <p className="text-muted-foreground max-w-2xl text-lg">
<br className="hidden sm:block" /> {vertical.description}
Discover shows, share ratings, and explore the music together. </p>
</p> </div>
<div className="flex flex-wrap justify-center gap-4 pt-4">
<Link href={`/${verticalSlug}/performances`}> {/* High-Level Stats */}
<Button size="lg" className="gap-2"> <div className="grid grid-cols-3 gap-8 text-center md:text-right">
<Trophy className="h-4 w-4" /> <div>
Top Performances <div className="text-3xl font-bold">{stats.total_shows || 0}</div>
</Button> <div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Shows</div>
</Link> </div>
<Link href={`/${verticalSlug}/shows`}> <div>
<Button size="lg" variant="outline" className="gap-2"> <div className="text-3xl font-bold">{stats.total_songs || 0}</div>
<Calendar className="h-4 w-4" /> <div className="text-sm text-muted-foreground font-medium uppercase tracking-wider">Songs</div>
Browse Shows </div>
</Button> <div>
</Link> <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 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 max-w-6xl space-y-12 px-4"> <div className="container px-4 space-y-12">
{/* Recent Shows */} <div className="grid gap-8 lg:grid-cols-2">
{recentShows.length > 0 && ( {/* Recent Shows Table */}
<section className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight">Recent Shows</h2> <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"> <Link href={`/${verticalSlug}/shows`}>
View all shows <ChevronRight className="h-4 w-4" /> <Button variant="ghost" size="sm" className="gap-1">
View All <ChevronRight className="h-4 w-4" />
</Button>
</Link> </Link>
</div> </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 */} <Card>
{topSongs.length > 0 && ( <Table>
<section className="space-y-4"> <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"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight">Most Played Songs</h2> <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"> <Link href={`/${verticalSlug}/songs`}>
View all songs <ChevronRight className="h-4 w-4" /> <Button variant="ghost" size="sm" className="gap-1">
View Catalog <ChevronRight className="h-4 w-4" />
</Button>
</Link> </Link>
</div> </div>
<div className="space-y-2">
{topSongs.slice(0, 5).map((song, index) => (
<Link
key={song.id}
href={`/${verticalSlug}/songs/${song.slug}`}
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 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 */} <Card>
<section className="space-y-4"> <Table>
<VideoGallery <TableHeader>
bandSlug={verticalSlug} <TableRow>
limit={8} <TableHead>Title</TableHead>
title="Recent Videos" <TableHead className="text-right">Plays</TableHead>
/> </TableRow>
</section> </TableHeader>
<TableBody>
{/* Navigation Cards */} {topSongs.length === 0 ? (
<section className="space-y-4"> <TableRow>
<h2 className="text-2xl font-bold tracking-tight">Explore</h2> <TableCell colSpan={2} className="text-center h-24 text-muted-foreground">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> No songs found
{navCards.map((card) => ( </TableCell>
<Link key={card.href} href={card.href} className="group"> </TableRow>
<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"> topSongs.map((song) => (
<div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors"> <TableRow key={song.id}>
<card.icon className="h-5 w-5" /> <TableCell className="font-medium">
</div> <Link href={`/${verticalSlug}/songs/${song.slug}`} className="hover:underline text-primary">
<div> {song.title}
<h3 className="font-semibold group-hover:text-primary transition-colors"> </Link>
{card.title} {song.original_artist && (
</h3> <span className="ml-2 text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground"> by {song.original_artist}
{card.desc} </span>
</p> )}
</div> </TableCell>
</CardContent> <TableCell className="text-right">
</Card> <Badge variant="secondary">{song.times_played || 0}</Badge>
</Link> </TableCell>
))} </TableRow>
))
)}
</TableBody>
</Table>
</Card>
</div> </div>
</section> </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 && (
<CommandGroup heading="Canonical Songs (Cross-Band Hub)">
{results.canonical_songs.map((song: any) => (
<CommandItem
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>
{song.original_artist && (
<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) => ( {results.songs.map((song: any) => (
<CommandItem key={song.slug || song.id} onSelect={() => handleSelect(`/songs/${song.slug}`)}> <CommandItem
<Music className="mr-2 h-4 w-4" /> key={song.id}
<div className="flex flex-col"> value={song.title}
<span>{song.title}</span> onSelect={() => handleSelect(`/${song.vertical?.slug || 'all'}/songs/${song.slug}`)}
{song.original_artist && ( >
<span className="text-[10px] text-muted-foreground">Original by {song.original_artist}</span> <Music className="mr-2 h-4 w-4 text-muted-foreground" />
)} <span>{song.title}</span>
</div> {song.vertical && (
<Badge variant="secondary" className="ml-2 text-[10px] h-5 px-1.5 font-normal">
{song.vertical.name}
</Badge>
)}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>