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() today = datetime.now()
if status == "past": if status == "past":
query = query.where(Show.date <= today) query = query.where(Show.date <= today)
query = query.order_by(Show.date.desc())
elif status == "upcoming": elif status == "upcoming":
query = query.where(Show.date > today) 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 # Default sort by date descending so we get recent shows first
query = query.order_by(Show.date.desc()) query = query.order_by(Show.date.desc())

View file

@ -15,6 +15,8 @@ class VerticalRead(BaseModel):
name: str name: str
slug: str slug: str
description: str | None = None description: str | None = None
logo_url: str | None = None
show_count: int = 0
class UserVerticalPreferenceRead(BaseModel): class UserVerticalPreferenceRead(BaseModel):
@ -54,30 +56,30 @@ def list_verticals(
scene: str | None = None, scene: str | None = None,
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
"""List all available verticals (bands), optionally filtered by scene""" """List all available verticals (bands) with show counts"""
from models import Scene, VerticalScene 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: if scene:
# Filter by scene query = query.join(VerticalScene).join(Scene).where(Scene.slug == 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")
vertical_ids = session.exec( query = query.group_by(Vertical.id).order_by(Vertical.name)
select(VerticalScene.vertical_id).where(VerticalScene.scene_id == scene_obj.id)
).all()
verticals = session.exec( results = session.exec(query).all()
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()
return verticals return [
VerticalRead(
**v.model_dump(),
logo_url=v.logo_url,
show_count=count
)
for v, count in results
]
class SceneRead(BaseModel): class SceneRead(BaseModel):

View file

@ -1,17 +1,14 @@
"use client" "use client"
import { useEffect, useState, Suspense, useMemo } from "react" import { useEffect, useState, Suspense } from "react"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { Loader2, Calendar, Music2, Clock, Heart, Users, X } from "lucide-react" import { Loader2, Music2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { useSearchParams, useRouter, usePathname } from "next/navigation" import { useSearchParams, useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button" 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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { DateGroupedList } from "@/components/shows/date-grouped-list" import { DateGroupedList } from "@/components/shows/date-grouped-list"
import { VERTICALS } from "@/config/verticals" import { FilterPills } from "@/components/shows/filter-pills"
import { Card, CardContent } from "@/components/ui/card" import { BandGrid } from "@/components/shows/band-grid"
interface Show { interface Show {
id: number id: number
@ -28,12 +25,15 @@ interface Show {
city: string city: string
state: string state: string
} }
performances?: any[] // Simplified
} }
interface VerticalWithCount { interface Vertical {
id: number
slug: string slug: string
name: string name: string
showCount?: number show_count: number
logo_url?: string | null
} }
function ShowsContent() { function ShowsContent() {
@ -41,345 +41,231 @@ function ShowsContent() {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Parse query params // --- State ---
const activeView = searchParams.get("view") || "recent"
const bandsParam = searchParams.get("bands") const bandsParam = searchParams.get("bands")
const viewParam = searchParams.get("view") || "recent" const selectedBands = bandsParam ? bandsParam.split(",") : []
const initialBands = bandsParam ? bandsParam.split(",") : []
const [shows, setShows] = useState<Show[]>([]) const [shows, setShows] = useState<Show[]>([])
const [upcomingShows, setUpcomingShows] = useState<Show[]>([]) const [verticals, setVerticals] = useState<Vertical[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedBands, setSelectedBands] = useState<string[]>(initialBands) const [loadingVerticals, setLoadingVerticals] = useState(true)
const [activeView, setActiveView] = useState(viewParam)
const [bandCounts, setBandCounts] = useState<Record<string, number>>({})
// Fetch band counts on mount // --- Data Fetching: Verticals (Always) ---
useEffect(() => { useEffect(() => {
setLoadingVerticals(true)
fetch(`${getApiUrl()}/verticals/`) fetch(`${getApiUrl()}/verticals/`)
.then(res => res.json()) .then(res => {
.then(verticals => { if (!res.ok) throw new Error("Failed to fetch verticals")
// We'll get counts from the shows data instead return res.json()
})
.then(data => {
setVerticals(data)
setLoadingVerticals(false)
})
.catch(err => {
console.error(err)
setLoadingVerticals(false)
}) })
.catch(console.error)
}, []) }, [])
// Update URL when filters change // --- Data Fetching: Shows (Dependent on View) ---
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
useEffect(() => { 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) setLoading(true)
const params = new URLSearchParams() 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) { 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()}` // Add view-specific params
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)
if (activeView === "upcoming") { 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 { } 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 }, [activeView, bandsParam]) // bandsParam is the dependency
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])
// Bands available for filtering // --- Handlers ---
const availableBands = VERTICALS.filter(v => const updateUrl = (view: string, bands: string[]) => {
activeBandsInData.includes(v.slug) || selectedBands.includes(v.slug) 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 ( return (
<div className="container py-8 space-y-6 animate-in fade-in duration-500"> <div className="container py-6 max-w-5xl">
{/* Header */} <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">
<div className="space-y-2"> <Tabs value={activeView} onValueChange={handleTabChange} className="w-full">
<h1 className="text-3xl font-bold tracking-tight">Shows</h1> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
<p className="text-muted-foreground"> <TabsList className="grid w-full md:w-auto grid-cols-4 h-11">
Browse the complete archive across all bands. <TabsTrigger value="recent" className="flex items-center gap-2">
</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" />
Recent Recent
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="bands" className="gap-2"> <TabsTrigger value="my-feed" className="flex items-center gap-2">
<Music2 className="h-4 w-4 hidden sm:block" /> My Feed
By Band
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="upcoming" className="gap-2"> <TabsTrigger value="upcoming" className="flex items-center gap-2">
<Calendar className="h-4 w-4 hidden sm:block" />
Upcoming Upcoming
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="feed" className="gap-2"> <TabsTrigger value="bands" className="flex items-center gap-2">
<Heart className="h-4 w-4 hidden sm:block" /> By Band
My Feed
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
{/* Band Filter Pills - Only show on Recent and Upcoming */} <FilterPills
{(activeView === "recent" || activeView === "upcoming") && ( selectedBands={selectedBands}
<div className="flex flex-wrap items-center gap-2 pt-4 border-t mt-4"> verticals={verticals}
<span className="text-sm text-muted-foreground mr-2">Filter:</span> onRemove={handleRemoveBand}
onClear={handleClearBands}
/>
{selectedBands.length === 0 ? ( <div className="mt-2 min-h-[500px]">
<Badge variant="secondary" className="bg-primary/10 text-primary"> <TabsContent value="recent" className="m-0">
All Bands {renderShowList()}
</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} />
)}
</TabsContent> </TabsContent>
<TabsContent value="bands" className="mt-6"> <TabsContent value="my-feed" className="m-0">
<BandsGrid /> {/* Maybe add auth check banner here if shows is empty and user not logged in? */}
{/* For now, just render list */}
{renderShowList()}
</TabsContent> </TabsContent>
<TabsContent value="upcoming" className="mt-6"> <TabsContent value="upcoming" className="m-0">
{loading ? ( {renderShowList()}
<LoadingSkeleton /> </TabsContent>
) : upcomingShows.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <TabsContent value="bands" className="m-0">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" /> {loadingVerticals ? (
<p className="text-lg font-medium">No upcoming shows announced</p> <div className="flex justify-center py-20">
<p className="text-sm mt-1">Check back later for new tour dates!</p> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div> </div>
) : ( ) : (
<DateGroupedList shows={upcomingShows} /> <BandGrid
verticals={verticals}
selectedBands={selectedBands}
onToggle={handleToggleBand} // Note: logic inside handleToggle moves to Recent
/>
)} )}
</TabsContent> </TabsContent>
</div>
<TabsContent value="feed" className="mt-6">
<MyFeedPlaceholder />
</TabsContent>
</Tabs> </Tabs>
</div> </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> </div>
) )
} }
export default function ShowsPage() { export default function ShowsPage() {
return ( return (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<div className="container py-6 flex justify-center"><Loader2 className="h-8 w-8 animate-spin" /></div>}>
<ShowsContent /> <ShowsContent />
</Suspense> </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>
)
}