feat: redesign global shows page with tabs, visible filters, bands grid
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
6cf9a100d4
commit
b1eed75b31
1 changed files with 290 additions and 82 deletions
|
|
@ -2,14 +2,16 @@
|
||||||
|
|
||||||
import { useEffect, useState, Suspense, useMemo } from "react"
|
import { useEffect, useState, Suspense, useMemo } from "react"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Loader2, Calendar } from "lucide-react"
|
import { Loader2, Calendar, Music2, Clock, Heart, Users, X } from "lucide-react"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
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 Link from "next/link"
|
||||||
import { BandFilter } from "@/components/shows/band-filter"
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
import { FeedFilter } from "@/components/shows/feed-filter"
|
|
||||||
import { DateGroupedList } from "@/components/shows/date-grouped-list"
|
import { DateGroupedList } from "@/components/shows/date-grouped-list"
|
||||||
|
import { VERTICALS } from "@/config/verticals"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
interface Show {
|
interface Show {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -28,135 +30,341 @@ interface Show {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VerticalWithCount {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
showCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
function ShowsContent() {
|
function ShowsContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const year = searchParams.get("year")
|
|
||||||
const bandsParam = searchParams.get("bands")
|
const bandsParam = searchParams.get("bands")
|
||||||
const tiersParam = searchParams.get("tiers")
|
const viewParam = searchParams.get("view") || "recent"
|
||||||
|
|
||||||
const initialBands = bandsParam ? bandsParam.split(",") : []
|
const initialBands = bandsParam ? bandsParam.split(",") : []
|
||||||
const initialTiers = tiersParam ? tiersParam.split(",") : []
|
|
||||||
|
|
||||||
const [shows, setShows] = useState<Show[]>([])
|
const [shows, setShows] = useState<Show[]>([])
|
||||||
|
const [upcomingShows, setUpcomingShows] = useState<Show[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selectedBands, setSelectedBands] = useState<string[]>(initialBands)
|
const [selectedBands, setSelectedBands] = useState<string[]>(initialBands)
|
||||||
const [selectedTiers, setSelectedTiers] = useState<string[]>(initialTiers)
|
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
|
// Update URL when filters change
|
||||||
const updateBandFilters = (bands: string[]) => {
|
const updateFilters = (bands: string[], view: string) => {
|
||||||
setSelectedBands(bands)
|
const params = new URLSearchParams()
|
||||||
updateUrl(bands, selectedTiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTierFilters = (tiers: string[]) => {
|
|
||||||
setSelectedTiers(tiers)
|
|
||||||
updateUrl(selectedBands, tiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateUrl = (bands: string[], tiers: string[]) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
|
|
||||||
if (bands.length > 0) {
|
if (bands.length > 0) {
|
||||||
params.set("bands", bands.join(","))
|
params.set("bands", bands.join(","))
|
||||||
} else {
|
|
||||||
params.delete("bands")
|
|
||||||
}
|
}
|
||||||
|
if (view !== "recent") {
|
||||||
if (tiers.length > 0) {
|
params.set("view", view)
|
||||||
params.set("tiers", tiers.join(","))
|
|
||||||
} else {
|
|
||||||
params.delete("tiers")
|
|
||||||
}
|
}
|
||||||
|
const queryString = params.toString()
|
||||||
router.push(`${pathname}?${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
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.append("limit", "200") // Lower limit for initial partial view, or keep 2000 if needed but likely smaller is better if filtered
|
params.append("limit", "200")
|
||||||
params.append("status", "past")
|
|
||||||
|
|
||||||
if (year) params.append("year", year)
|
if (activeView === "recent") {
|
||||||
|
params.append("status", "past")
|
||||||
|
} else if (activeView === "upcoming") {
|
||||||
|
params.append("status", "upcoming")
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedBands.length > 0) {
|
if (selectedBands.length > 0) {
|
||||||
selectedBands.forEach(slug => params.append("vertical_slugs", slug))
|
selectedBands.forEach(slug => params.append("vertical_slugs", slug))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTiers.length > 0) {
|
|
||||||
selectedTiers.forEach(tier => params.append("tiers", tier))
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${getApiUrl()}/shows/?${params.toString()}`
|
const url = `${getApiUrl()}/shows/?${params.toString()}`
|
||||||
|
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Backend might not sort perfectly if multiple bands (it sorts by offset usually, or default order).
|
|
||||||
// Currently backend read_shows does NO sorting (unless I missed it).
|
|
||||||
// read_shows only does offset/limit.
|
|
||||||
// So I should sort client side or add sort to backend.
|
|
||||||
// Assuming backend returns unsorted or DB order.
|
|
||||||
const sorted = data.sort((a: Show, b: Show) =>
|
const sorted = data.sort((a: Show, b: Show) =>
|
||||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
activeView === "upcoming"
|
||||||
|
? new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
|
: new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
)
|
)
|
||||||
setShows(sorted)
|
|
||||||
|
// 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)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [year, selectedBands, selectedTiers])
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container py-10 space-y-8">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div className="flex flex-col gap-2">
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<Skeleton className="h-10 w-48" />
|
<Skeleton key={i} className="h-24 rounded-lg" />
|
||||||
<Skeleton className="h-5 w-96" />
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-32 w-full rounded-lg" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-10 space-y-8 animate-in fade-in duration-700">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div className="flex flex-col gap-4">
|
{verticals.map(vertical => (
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<Link key={vertical.id} href={`/${vertical.slug}/shows`}>
|
||||||
<div>
|
<Card className="h-full transition-all hover:scale-[1.02] hover:shadow-lg hover:border-primary/50 cursor-pointer">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Shows</h1>
|
<CardContent className="p-4">
|
||||||
<p className="text-muted-foreground">
|
<div className="flex items-center gap-3">
|
||||||
Browse the complete archive of performances.
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
</p>
|
<Music2 className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<FeedFilter
|
<h3 className="font-semibold truncate">{vertical.name}</h3>
|
||||||
selectedTiers={selectedTiers}
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
onChange={updateTierFilters}
|
{vertical.description?.slice(0, 50)}...
|
||||||
/>
|
</p>
|
||||||
<BandFilter
|
</div>
|
||||||
selected={selectedBands}
|
</div>
|
||||||
onChange={updateBandFilters}
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
<Link href="/shows/upcoming">
|
</Link>
|
||||||
<Button variant="outline" className="gap-2">
|
))}
|
||||||
<Calendar className="h-4 w-4" />
|
</div>
|
||||||
Upcoming
|
)
|
||||||
</Button>
|
}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<DateGroupedList shows={shows} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue