feat: redesign band hub page and populate song stats
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
f10f8ad465
commit
18b102558d
5 changed files with 263 additions and 164 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue