fediversion/frontend/app/shows/page.tsx
fullsizemalt b1eed75b31
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
feat: redesign global shows page with tabs, visible filters, bands grid
2025-12-30 18:28:36 -08:00

386 lines
15 KiB
TypeScript

"use client"
import { useEffect, useState, Suspense, useMemo } 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 { 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"
interface Show {
id: number
slug?: string
date: string
youtube_link?: string
vertical?: {
name: string
slug: string
}
venue: {
id: number
name: string
city: string
state: string
}
}
interface VerticalWithCount {
slug: string
name: string
showCount?: number
}
function ShowsContent() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
// Parse query params
const bandsParam = searchParams.get("bands")
const viewParam = searchParams.get("view") || "recent"
const initialBands = bandsParam ? bandsParam.split(",") : []
const [shows, setShows] = useState<Show[]>([])
const [upcomingShows, setUpcomingShows] = useState<Show[]>([])
const [loading, setLoading] = useState(true)
const [selectedBands, setSelectedBands] = useState<string[]>(initialBands)
const [activeView, setActiveView] = useState(viewParam)
const [bandCounts, setBandCounts] = useState<Record<string, number>>({})
// Fetch band counts on mount
useEffect(() => {
fetch(`${getApiUrl()}/verticals/`)
.then(res => res.json())
.then(verticals => {
// We'll get counts from the shows data instead
})
.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
useEffect(() => {
if (activeView === "bands") return // No fetch needed for bands view
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")
}
if (selectedBands.length > 0) {
selectedBands.forEach(slug => params.append("vertical_slugs", slug))
}
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)
if (activeView === "upcoming") {
setUpcomingShows(sorted)
} else {
setShows(sorted)
}
})
.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])
// Bands available for filtering
const availableBands = VERTICALS.filter(v =>
activeBandsInData.includes(v.slug) || selectedBands.includes(v.slug)
)
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" />
Recent
</TabsTrigger>
<TabsTrigger value="bands" className="gap-2">
<Music2 className="h-4 w-4 hidden sm:block" />
By Band
</TabsTrigger>
<TabsTrigger value="upcoming" className="gap-2">
<Calendar className="h-4 w-4 hidden sm:block" />
Upcoming
</TabsTrigger>
<TabsTrigger value="feed" className="gap-2">
<Heart className="h-4 w-4 hidden sm:block" />
My Feed
</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>
{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} />
)}
</TabsContent>
<TabsContent value="bands" className="mt-6">
<BandsGrid />
</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>
</div>
) : (
<DateGroupedList shows={upcomingShows} />
)}
</TabsContent>
<TabsContent value="feed" className="mt-6">
<MyFeedPlaceholder />
</TabsContent>
</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 />}>
<ShowsContent />
</Suspense>
)
}