feat(shows): redesign global shows hub
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

- Frontend: Implemented Tabbed interface (Recent, My Feed, Upcoming, By Band)
- Frontend: Added BandGrid component with selection logic
- Frontend: Added FilterPills component for active filters
- Backend: Added show_count to Verticals API
- Backend: Updated read_shows to support correct sorting for Upcoming status
This commit is contained in:
fullsizemalt 2025-12-30 20:18:10 -08:00
parent c090a395dc
commit c860075681
5 changed files with 339 additions and 332 deletions

View file

@ -91,9 +91,11 @@ def read_shows(
today = datetime.now()
if status == "past":
query = query.where(Show.date <= today)
query = query.order_by(Show.date.desc())
elif status == "upcoming":
query = query.where(Show.date > today)
query = query.order_by(Show.date.asc())
else:
# Default sort by date descending so we get recent shows first
query = query.order_by(Show.date.desc())

View file

@ -15,6 +15,8 @@ class VerticalRead(BaseModel):
name: str
slug: str
description: str | None = None
logo_url: str | None = None
show_count: int = 0
class UserVerticalPreferenceRead(BaseModel):
@ -54,30 +56,30 @@ def list_verticals(
scene: str | None = None,
session: Session = Depends(get_session)
):
"""List all available verticals (bands), optionally filtered by scene"""
from models import Scene, VerticalScene
"""List all available verticals (bands) with show counts"""
from models import Show, VerticalScene, Scene
from sqlalchemy import func
# Base query: Active verticals with show count
query = select(Vertical, func.count(Show.id).label("show_count")) \
.outerjoin(Show, Vertical.id == Show.vertical_id) \
.where(Vertical.is_active == True)
if scene:
# Filter by scene
scene_obj = session.exec(select(Scene).where(Scene.slug == scene)).first()
if not scene_obj:
raise HTTPException(status_code=404, detail="Scene not found")
query = query.join(VerticalScene).join(Scene).where(Scene.slug == scene)
vertical_ids = session.exec(
select(VerticalScene.vertical_id).where(VerticalScene.scene_id == scene_obj.id)
).all()
query = query.group_by(Vertical.id).order_by(Vertical.name)
verticals = session.exec(
select(Vertical)
.where(Vertical.id.in_(vertical_ids))
.where(Vertical.is_active == True)
).all()
else:
verticals = session.exec(
select(Vertical).where(Vertical.is_active == True)
).all()
results = session.exec(query).all()
return verticals
return [
VerticalRead(
**v.model_dump(),
logo_url=v.logo_url,
show_count=count
)
for v, count in results
]
class SceneRead(BaseModel):

View file

@ -1,17 +1,14 @@
"use client"
import { useEffect, useState, Suspense, useMemo } from "react"
import { useEffect, useState, Suspense } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Loader2, Calendar, Music2, Clock, Heart, Users, X } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Loader2, Music2 } from "lucide-react"
import { useSearchParams, useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import Link from "next/link"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { DateGroupedList } from "@/components/shows/date-grouped-list"
import { VERTICALS } from "@/config/verticals"
import { Card, CardContent } from "@/components/ui/card"
import { FilterPills } from "@/components/shows/filter-pills"
import { BandGrid } from "@/components/shows/band-grid"
interface Show {
id: number
@ -28,12 +25,15 @@ interface Show {
city: string
state: string
}
performances?: any[] // Simplified
}
interface VerticalWithCount {
interface Vertical {
id: number
slug: string
name: string
showCount?: number
show_count: number
logo_url?: string | null
}
function ShowsContent() {
@ -41,345 +41,231 @@ function ShowsContent() {
const router = useRouter()
const pathname = usePathname()
// Parse query params
// --- State ---
const activeView = searchParams.get("view") || "recent"
const bandsParam = searchParams.get("bands")
const viewParam = searchParams.get("view") || "recent"
const initialBands = bandsParam ? bandsParam.split(",") : []
const selectedBands = bandsParam ? bandsParam.split(",") : []
const [shows, setShows] = useState<Show[]>([])
const [upcomingShows, setUpcomingShows] = useState<Show[]>([])
const [verticals, setVerticals] = useState<Vertical[]>([])
const [loading, setLoading] = useState(true)
const [selectedBands, setSelectedBands] = useState<string[]>(initialBands)
const [activeView, setActiveView] = useState(viewParam)
const [bandCounts, setBandCounts] = useState<Record<string, number>>({})
const [loadingVerticals, setLoadingVerticals] = useState(true)
// Fetch band counts on mount
// --- Data Fetching: Verticals (Always) ---
useEffect(() => {
setLoadingVerticals(true)
fetch(`${getApiUrl()}/verticals/`)
.then(res => res.json())
.then(verticals => {
// We'll get counts from the shows data instead
.then(res => {
if (!res.ok) throw new Error("Failed to fetch verticals")
return res.json()
})
.then(data => {
setVerticals(data)
setLoadingVerticals(false)
})
.catch(err => {
console.error(err)
setLoadingVerticals(false)
})
.catch(console.error)
}, [])
// Update URL when filters change
const updateFilters = (bands: string[], view: string) => {
const params = new URLSearchParams()
if (bands.length > 0) {
params.set("bands", bands.join(","))
}
if (view !== "recent") {
params.set("view", view)
}
const queryString = params.toString()
router.push(`${pathname}${queryString ? `?${queryString}` : ''}`)
}
const toggleBand = (slug: string) => {
const newBands = selectedBands.includes(slug)
? selectedBands.filter(s => s !== slug)
: [...selectedBands, slug]
setSelectedBands(newBands)
updateFilters(newBands, activeView)
}
const clearBandFilters = () => {
setSelectedBands([])
updateFilters([], activeView)
}
const handleViewChange = (view: string) => {
setActiveView(view)
updateFilters(selectedBands, view)
}
// Fetch shows based on view
// --- Data Fetching: Shows (Dependent on View) ---
useEffect(() => {
if (activeView === "bands") return // No fetch needed for bands view
if (activeView === "bands") {
// Don't fetch shows if we are just browsing bands
setLoading(false)
return
}
setLoading(true)
const params = new URLSearchParams()
params.append("limit", "200")
if (activeView === "recent") {
params.append("status", "past")
} else if (activeView === "upcoming") {
params.append("status", "upcoming")
}
// Add band filters
if (selectedBands.length > 0) {
selectedBands.forEach(slug => params.append("vertical_slugs", slug))
selectedBands.forEach(b => params.append("vertical_slugs", b))
}
const url = `${getApiUrl()}/shows/?${params.toString()}`
fetch(url)
.then(res => res.json())
.then(data => {
const sorted = data.sort((a: Show, b: Show) =>
activeView === "upcoming"
? new Date(a.date).getTime() - new Date(b.date).getTime()
: new Date(b.date).getTime() - new Date(a.date).getTime()
)
// Calculate band counts from data
const counts: Record<string, number> = {}
data.forEach((show: Show) => {
if (show.vertical?.slug) {
counts[show.vertical.slug] = (counts[show.vertical.slug] || 0) + 1
}
})
setBandCounts(counts)
// Add view-specific params
if (activeView === "upcoming") {
setUpcomingShows(sorted)
params.set("status", "upcoming")
} else if (activeView === "my-feed") {
// My Feed implies specific tiers
// We use the same tiers as FeedFilter used: HEADLINER, MAIN_STAGE, SUPPORTING
["HEADLINER", "MAIN_STAGE", "SUPPORTING"].forEach(t => params.append("tiers", t))
// Also we might want to default to "past" shows for feed? Or all?
// "My Feed" usually means recent updates.
// Let's explicitly ask for "past" shows (recent history) unless user wants upcoming feed?
// For now, let's show PAST shows in My Feed (History), maybe add Upcoming toggle later?
// Or just show all? `read_shows` sorts by date desc.
// Let's default to Recent (Past) for Feed.
// params.set("status", "past")
// Actually, if we leave status blank, it returns all (sorted by date desc if modified, but check `read_shows`)
// `read_shows` default sorts DESC.
// Let's assume user wants recent feed.
} else {
setShows(sorted)
// Recent (Default)
params.set("status", "past")
}
fetch(`${getApiUrl()}/shows/?${params.toString()}`)
.then(res => {
// If 401 (Unauthorized) for My Feed, we might get empty list or error
if (res.status === 401 && activeView === "my-feed") {
// Redirect to login or handle?
// For now, shows API returns [] if anon try to filter by tiers.
return []
}
if (!res.ok) throw new Error("Failed to fetch shows")
return res.json()
})
.then(data => {
setShows(data)
setLoading(false)
})
.catch(err => {
console.error(err)
setShows([]) // Clear on error
setLoading(false)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [activeView, selectedBands])
// Get unique bands from current data for filter pills
const activeBandsInData = useMemo(() => {
const dataToCheck = activeView === "upcoming" ? upcomingShows : shows
const slugs = new Set(dataToCheck.map(s => s.vertical?.slug).filter(Boolean))
return Array.from(slugs) as string[]
}, [shows, upcomingShows, activeView])
}, [activeView, bandsParam]) // bandsParam is the dependency
// Bands available for filtering
const availableBands = VERTICALS.filter(v =>
activeBandsInData.includes(v.slug) || selectedBands.includes(v.slug)
// --- Handlers ---
const updateUrl = (view: string, bands: string[]) => {
const params = new URLSearchParams()
if (view !== "recent") params.set("view", view)
if (bands.length > 0) params.set("bands", bands.join(","))
// Push state
router.push(`${pathname}${params.toString() ? `?${params.toString()}` : ''}`)
}
const handleTabChange = (val: string) => {
updateUrl(val, selectedBands)
}
const handleToggleBand = (slug: string) => {
let newBands = [...selectedBands]
if (newBands.includes(slug)) {
newBands = newBands.filter(b => b !== slug)
} else {
newBands.push(slug)
}
// If we are on "bands" tab and select a band, switch to "recent" to show results?
// User plan says: "Clicking a band adds it to the active filter and switches to 'Recent' view."
if (activeView === "bands" && !selectedBands.includes(slug)) {
updateUrl("recent", newBands)
} else {
// Otherwise just update filters (e.g. if unchecking, stay on grid? or if adding from elsewhere?)
// If checking from grid -> go to recent.
// If unchecking -> stay?
// Let's just follow the rule: Select -> Go to Recent. Unselect -> Stay.
if (!selectedBands.includes(slug)) {
updateUrl("recent", newBands)
} else {
updateUrl(activeView, newBands)
}
}
}
const handleRemoveBand = (slug: string) => {
const newBands = selectedBands.filter(b => b !== slug)
updateUrl(activeView, newBands)
}
const handleClearBands = () => {
updateUrl(activeView, [])
}
// --- Render Helpers ---
const renderShowList = () => {
if (loading) {
return (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (shows.length === 0) {
return (
<div className="text-center py-20 text-muted-foreground">
<Music2 className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p>No shows found matching your criteria.</p>
</div>
)
}
return <DateGroupedList shows={shows} />
}
return (
<div className="container py-8 space-y-6 animate-in fade-in duration-500">
{/* Header */}
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Shows</h1>
<p className="text-muted-foreground">
Browse the complete archive across all bands.
</p>
</div>
{/* Tabs Navigation */}
<Tabs value={activeView} onValueChange={handleViewChange} className="w-full">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<TabsList className="grid w-full sm:w-auto grid-cols-4 sm:grid-cols-4">
<TabsTrigger value="recent" className="gap-2">
<Clock className="h-4 w-4 hidden sm:block" />
<div className="container py-6 max-w-5xl">
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 -mx-4 px-4 md:mx-0 md:px-0">
<Tabs value={activeView} onValueChange={handleTabChange} className="w-full">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
<TabsList className="grid w-full md:w-auto grid-cols-4 h-11">
<TabsTrigger value="recent" className="flex items-center gap-2">
Recent
</TabsTrigger>
<TabsTrigger value="bands" className="gap-2">
<Music2 className="h-4 w-4 hidden sm:block" />
By Band
<TabsTrigger value="my-feed" className="flex items-center gap-2">
My Feed
</TabsTrigger>
<TabsTrigger value="upcoming" className="gap-2">
<Calendar className="h-4 w-4 hidden sm:block" />
<TabsTrigger value="upcoming" className="flex items-center gap-2">
Upcoming
</TabsTrigger>
<TabsTrigger value="feed" className="gap-2">
<Heart className="h-4 w-4 hidden sm:block" />
My Feed
<TabsTrigger value="bands" className="flex items-center gap-2">
By Band
</TabsTrigger>
</TabsList>
</div>
{/* Band Filter Pills - Only show on Recent and Upcoming */}
{(activeView === "recent" || activeView === "upcoming") && (
<div className="flex flex-wrap items-center gap-2 pt-4 border-t mt-4">
<span className="text-sm text-muted-foreground mr-2">Filter:</span>
<FilterPills
selectedBands={selectedBands}
verticals={verticals}
onRemove={handleRemoveBand}
onClear={handleClearBands}
/>
{selectedBands.length === 0 ? (
<Badge variant="secondary" className="bg-primary/10 text-primary">
All Bands
</Badge>
) : (
<>
{selectedBands.map(slug => {
const band = VERTICALS.find(v => v.slug === slug)
return (
<Badge
key={slug}
variant="secondary"
className="gap-1 cursor-pointer hover:bg-destructive/20 transition-colors"
onClick={() => toggleBand(slug)}
>
{band?.name || slug}
<X className="h-3 w-3" />
</Badge>
)
})}
<Button
variant="ghost"
size="sm"
onClick={clearBandFilters}
className="h-6 text-xs text-muted-foreground"
>
Clear all
</Button>
</>
)}
{/* Add band buttons */}
{selectedBands.length === 0 && availableBands.length > 0 && (
<div className="flex flex-wrap gap-1 ml-2">
{availableBands.slice(0, 5).map(band => (
<Badge
key={band.slug}
variant="outline"
className="cursor-pointer hover:bg-primary/10 transition-colors"
onClick={() => toggleBand(band.slug)}
>
{band.name}
{bandCounts[band.slug] && (
<span className="ml-1 text-muted-foreground">
({bandCounts[band.slug]})
</span>
)}
</Badge>
))}
{availableBands.length > 5 && (
<Badge variant="outline" className="text-muted-foreground">
+{availableBands.length - 5} more
</Badge>
)}
</div>
)}
</div>
)}
{/* Tab Content */}
<TabsContent value="recent" className="mt-6">
{loading ? (
<LoadingSkeleton />
) : (
<DateGroupedList shows={shows} />
)}
<div className="mt-2 min-h-[500px]">
<TabsContent value="recent" className="m-0">
{renderShowList()}
</TabsContent>
<TabsContent value="bands" className="mt-6">
<BandsGrid />
<TabsContent value="my-feed" className="m-0">
{/* Maybe add auth check banner here if shows is empty and user not logged in? */}
{/* For now, just render list */}
{renderShowList()}
</TabsContent>
<TabsContent value="upcoming" className="mt-6">
{loading ? (
<LoadingSkeleton />
) : upcomingShows.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">No upcoming shows announced</p>
<p className="text-sm mt-1">Check back later for new tour dates!</p>
<TabsContent value="upcoming" className="m-0">
{renderShowList()}
</TabsContent>
<TabsContent value="bands" className="m-0">
{loadingVerticals ? (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<DateGroupedList shows={upcomingShows} />
<BandGrid
verticals={verticals}
selectedBands={selectedBands}
onToggle={handleToggleBand} // Note: logic inside handleToggle moves to Recent
/>
)}
</TabsContent>
<TabsContent value="feed" className="mt-6">
<MyFeedPlaceholder />
</TabsContent>
</div>
</Tabs>
</div>
)
}
function BandsGrid() {
const [verticals, setVerticals] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch(`${getApiUrl()}/verticals/`)
.then(res => res.json())
.then(data => setVerticals(data))
.catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-lg" />
))}
</div>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{verticals.map(vertical => (
<Link key={vertical.id} href={`/${vertical.slug}/shows`}>
<Card className="h-full transition-all hover:scale-[1.02] hover:shadow-lg hover:border-primary/50 cursor-pointer">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Music2 className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{vertical.name}</h3>
<p className="text-sm text-muted-foreground truncate">
{vertical.description?.slice(0, 50)}...
</p>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)
}
function MyFeedPlaceholder() {
return (
<div className="text-center py-12">
<Users className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Your Personal Feed</h3>
<p className="text-muted-foreground max-w-md mx-auto mb-4">
Follow your favorite bands to see a customized feed of shows tailored to your preferences.
</p>
<div className="flex gap-2 justify-center">
<Link href="/register">
<Button>Create Account</Button>
</Link>
<Link href="/login">
<Button variant="outline">Sign In</Button>
</Link>
</div>
</div>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-8">
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
</div>
</div>
)
}
function LoadingFallback() {
return (
<div className="container py-10 flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
export default function ShowsPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<Suspense fallback={<div className="container py-6 flex justify-center"><Loader2 className="h-8 w-8 animate-spin" /></div>}>
<ShowsContent />
</Suspense>
)

View file

@ -0,0 +1,66 @@
"use client"
import { Card, CardContent } from "@/components/ui/card"
import { Music2, Check } from "lucide-react"
import { Badge } from "@/components/ui/badge"
interface Vertical {
id: number
slug: string
name: string
show_count: number
logo_url?: string | null
}
interface BandGridProps {
verticals: Vertical[]
selectedBands: string[]
onToggle: (slug: string) => void
}
export function BandGrid({ verticals, selectedBands, onToggle }: BandGridProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{verticals.map((v) => {
const isSelected = selectedBands.includes(v.slug)
return (
<Card
key={v.id}
className={`cursor-pointer transition-all hover:shadow-md ${isSelected ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:border-primary/50"
}`}
onClick={() => onToggle(v.slug)}
>
<CardContent className="flex flex-col items-center justify-center p-6 text-center gap-3 relative">
{isSelected && (
<div className="absolute top-2 right-2">
<Badge variant="default" className="h-5 w-5 p-0 flex items-center justify-center rounded-full">
<Check className="h-3 w-3" />
</Badge>
</div>
)}
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center overflow-hidden shadow-sm border border-border">
{v.logo_url ? (
<img
src={v.logo_url}
alt={v.name}
className="h-full w-full object-cover"
/>
) : (
<Music2 className="h-8 w-8 text-muted-foreground/50" />
)}
</div>
<div className="space-y-1">
<h3 className="font-semibold leading-tight">{v.name}</h3>
<p className="text-xs text-muted-foreground font-medium">
{v.show_count.toLocaleString()} shows
</p>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View file

@ -0,0 +1,51 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
interface Vertical {
slug: string
name: string
}
interface FilterPillsProps {
selectedBands: string[]
verticals: Vertical[]
onRemove: (slug: string) => void
onClear: () => void
}
export function FilterPills({ selectedBands, verticals, onRemove, onClear }: FilterPillsProps) {
if (selectedBands.length === 0) return null
return (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-sm text-muted-foreground mr-2">Filtering by:</span>
{selectedBands.map(slug => {
const band = verticals.find(v => v.slug === slug)
return (
<Badge key={slug} variant="secondary" className="flex items-center gap-1 pl-2 pr-1 py-1">
{band?.name || slug}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 ml-1 hover:bg-transparent hover:text-destructive"
onClick={() => onRemove(slug)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
)
})}
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground h-7"
onClick={onClear}
>
Clear all
</Button>
</div>
)
}