diff --git a/backend/routers/search.py b/backend/routers/search.py index 7ab9aaf..b92a179 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select, col from sqlalchemy.orm import selectinload 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"]) @@ -18,13 +18,32 @@ def global_search( q_str = f"%{q}%" - # Search Songs - songs = session.exec( + # Search Canonical Songs (The Hub) + 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) + .options(selectinload(Song.vertical)) .where(col(Song.title).ilike(q_str)) .limit(limit) ).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 venues = session.exec( select(Venue) @@ -118,6 +137,7 @@ def global_search( ).all() return { + "canonical_songs": canonical_songs, "songs": songs, "venues": venues, "tours": tours, diff --git a/backend/routers/songs.py b/backend/routers/songs.py index a2cb920..b73dae7 100644 --- a/backend/routers/songs.py +++ b/backend/routers/songs.py @@ -37,15 +37,33 @@ def read_songs( return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset)) 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) - - # Calculate total count before pagination - total = session.exec(select(func.count()).select_from(query.subquery())).one() - - if sort == "times_played": - query = query.order_by(func.count(Performance.id).desc()) + + if vertical: + query = query.where(Song.vertical_id == vertical_entity.id) - songs = session.exec(query.offset(offset).limit(limit)).all() + # 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() + + 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() return PaginatedResponse( data=songs, diff --git a/backend/schemas.py b/backend/schemas.py index 814320f..eb2455f 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -85,6 +85,7 @@ class SongRead(SongBase): tags: List["TagRead"] = [] artist: Optional["ArtistRead"] = None vertical: Optional["VerticalSimple"] = None + times_played: Optional[int] = 0 diff --git a/frontend/app/[vertical]/page.tsx b/frontend/app/[vertical]/page.tsx index db49a10..d6906ae 100644 --- a/frontend/app/[vertical]/page.tsx +++ b/frontend/app/[vertical]/page.tsx @@ -5,7 +5,15 @@ import Link from "next/link" import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight } from "lucide-react" import { Card, CardContent } from "@/components/ui/card" 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" interface Props { @@ -20,7 +28,8 @@ export function generateStaticParams() { async function getRecentShows(verticalSlug: string): Promise { 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 } }) if (!res.ok) return [] @@ -33,7 +42,8 @@ async function getRecentShows(verticalSlug: string): Promise { async function getTopSongs(verticalSlug: string): Promise { 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 } }) if (!res.ok) return [] @@ -44,6 +54,18 @@ async function getTopSongs(verticalSlug: string): Promise { } } +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) { const { vertical: verticalSlug } = await params const vertical = VERTICALS.find((v) => v.slug === verticalSlug) @@ -52,166 +74,178 @@ export default async function VerticalPage({ params }: Props) { notFound() } - const [recentShows, topSongs] = await Promise.all([ + const [recentShows, topSongs, bandProfile] = await Promise.all([ getRecentShows(verticalSlug), - getTopSongs(verticalSlug) + getTopSongs(verticalSlug), + getVerticalStats(verticalSlug) ]) - const navCards = [ - { 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" }, - ] + const stats = bandProfile?.stats || {} return ( -
- {/* Hero Section */} -
-

- {vertical.name} -

-

- A comprehensive community-driven archive for {vertical.name} history. -
- Discover shows, share ratings, and explore the music together. -

-
- - - - - - +
+ {/* Hero Section - Compact & Utilitarian */} +
+
+
+
+

{vertical.name}

+

+ {vertical.description} +

+
+ + {/* High-Level Stats */} +
+
+
{stats.total_shows || 0}
+
Shows
+
+
+
{stats.total_songs || 0}
+
Songs
+
+
+
{stats.total_venues || 0}
+
Venues
+
+
+
+ + {/* Quick Actions Bar */} +
+ + + + + + + + + + + + + + + +
-
- {/* Recent Shows */} - {recentShows.length > 0 && ( -
+
+
+ {/* Recent Shows Table */} +

Recent Shows

- - View all shows + +
-
- {recentShows.slice(0, 8).map((show) => ( - - - -
- {new Date(show.date).toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric' - })} -
-
- {show.venue?.name || "Unknown Venue"} -
-
- - {show.venue?.city}, {show.venue?.state || show.venue?.country} -
- {/* Tour is not in strict Show model yet, omitting for strictness or need to add to model */} - {/* {show.tour?.name && ( -
- {show.tour.name} -
- )} */} -
-
- - ))} -
-
- )} - {/* Most Played Songs */} - {topSongs.length > 0 && ( -
+ + + + + Date + Venue + Location + + + + {recentShows.length === 0 ? ( + + + No shows found + + + ) : ( + recentShows.map((show) => ( + + + + {new Date(show.date).toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric' + })} + + + + {show.venue?.name || "Unknown"} + + + {show.venue?.city}, {show.venue?.state} + + + )) + )} + +
+
+
+ + {/* Most Played Songs Table */} +

Most Played Songs

- - View all songs + +
-
- {topSongs.slice(0, 5).map((song, index) => ( - -
-
- {index + 1} -
-
-
- {song.title} -
-
-
- {song.times_played || 0} performances -
-
- - ))} -
-
- )} - {/* Videos Section */} -
- -
- - {/* Navigation Cards */} -
-

Explore

-
- {navCards.map((card) => ( - - - -
- -
-
-

- {card.title} -

-

- {card.desc} -

-
-
-
- - ))} + + + + + Title + Plays + + + + {topSongs.length === 0 ? ( + + + No songs found + + + ) : ( + topSongs.map((song) => ( + + + + {song.title} + + {song.original_artist && ( + + by {song.original_artist} + + )} + + + {song.times_played || 0} + + + )) + )} + +
+
-
+
) diff --git a/frontend/components/ui/search-dialog.tsx b/frontend/components/ui/search-dialog.tsx index c6b6238..aaa754a 100644 --- a/frontend/components/ui/search-dialog.tsx +++ b/frontend/components/ui/search-dialog.tsx @@ -136,17 +136,43 @@ export function SearchDialog() { )} - {results.songs?.length > 0 && ( - + {/* Canonical Songs (Hub Results) */} + {results.canonical_songs && results.canonical_songs.length > 0 && ( + + {results.canonical_songs.map((song: any) => ( + handleSelect(`/songs/${song.slug}`)} + > + + {song.title} + {song.original_artist && ( + + (Orig. {song.original_artist}) + + )} + + ))} + + )} + + {/* Artist Specific Songs */} + {results.songs && results.songs.length > 0 && ( + {results.songs.map((song: any) => ( - handleSelect(`/songs/${song.slug}`)}> - -
- {song.title} - {song.original_artist && ( - Original by {song.original_artist} - )} -
+ handleSelect(`/${song.vertical?.slug || 'all'}/songs/${song.slug}`)} + > + + {song.title} + {song.vertical && ( + + {song.vertical.name} + + )} ))}