feat(frontend): implement date-grouped show list and band filter for All Bands view
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
af9fcd4060
commit
7c9bcd81a6
3 changed files with 249 additions and 58 deletions
|
|
@ -1,19 +1,24 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from "react"
|
import { useEffect, useState, Suspense, useMemo } from "react"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Loader2, Calendar } from "lucide-react"
|
||||||
import Link from "next/link"
|
|
||||||
import { Calendar, MapPin, Loader2, Youtube } from "lucide-react"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { BandFilter } from "@/components/shows/band-filter"
|
||||||
|
import { DateGroupedList } from "@/components/shows/date-grouped-list"
|
||||||
|
|
||||||
interface Show {
|
interface Show {
|
||||||
id: number
|
id: number
|
||||||
slug?: string
|
slug?: string
|
||||||
date: string
|
date: string
|
||||||
youtube_link?: string
|
youtube_link?: string
|
||||||
|
vertical?: {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
venue: {
|
venue: {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -28,6 +33,7 @@ function ShowsContent() {
|
||||||
|
|
||||||
const [shows, setShows] = useState<Show[]>([])
|
const [shows, setShows] = useState<Show[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedBands, setSelectedBands] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = `${getApiUrl()}/shows/?limit=2000&status=past${year ? `&year=${year}` : ''}`
|
const url = `${getApiUrl()}/shows/?limit=2000&status=past${year ? `&year=${year}` : ''}`
|
||||||
|
|
@ -44,6 +50,14 @@ function ShowsContent() {
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [year])
|
}, [year])
|
||||||
|
|
||||||
|
// Filter shows locally based on selection
|
||||||
|
const filteredShows = useMemo(() => {
|
||||||
|
if (selectedBands.length === 0) return shows
|
||||||
|
return shows.filter(show =>
|
||||||
|
show.vertical && selectedBands.includes(show.vertical.slug)
|
||||||
|
)
|
||||||
|
}, [shows, selectedBands])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="container py-10 space-y-8">
|
<div className="container py-10 space-y-8">
|
||||||
|
|
@ -54,20 +68,7 @@ function ShowsContent() {
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
<Card key={i} className="h-full border-muted/40">
|
<Skeleton key={i} className="h-32 w-full rounded-lg" />
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-5 w-5 rounded-full" />
|
|
||||||
<Skeleton className="h-5 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-4 rounded-full" />
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,55 +77,30 @@ function ShowsContent() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-10 space-y-8 animate-in fade-in duration-700">
|
<div className="container py-10 space-y-8 animate-in fade-in duration-700">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Shows</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Shows</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Browse the complete archive of performances.
|
Browse the complete archive of performances.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BandFilter
|
||||||
|
selected={selectedBands}
|
||||||
|
onChange={setSelectedBands}
|
||||||
|
/>
|
||||||
<Link href="/shows/upcoming">
|
<Link href="/shows/upcoming">
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
Upcoming Shows
|
Upcoming
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<DateGroupedList shows={filteredShows} />
|
||||||
{shows.map((show) => (
|
|
||||||
<Link key={show.id} href={`/shows/${show.slug}`} className="block group">
|
|
||||||
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50 relative">
|
|
||||||
{show.youtube_link && (
|
|
||||||
<div className="absolute top-2 right-2 bg-red-500/10 text-red-500 p-1.5 rounded-full" title="Full show video available">
|
|
||||||
<Youtube className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 group-hover:text-primary transition-colors">
|
|
||||||
<Calendar className="h-5 w-5 text-muted-foreground group-hover:text-primary/70 transition-colors" />
|
|
||||||
{new Date(show.date).toLocaleDateString(undefined, {
|
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground group-hover:text-foreground transition-colors">
|
|
||||||
<MapPin className="h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
{show.venue?.name}, {show.venue?.city}, {show.venue?.state}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
91
frontend/components/shows/band-filter.tsx
Normal file
91
frontend/components/shows/band-filter.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { VERTICALS } from "@/config/verticals"
|
||||||
|
import { Filter } from "lucide-react"
|
||||||
|
|
||||||
|
interface BandFilterProps {
|
||||||
|
selected: string[]
|
||||||
|
onChange: (selected: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BandFilter({ selected, onChange }: BandFilterProps) {
|
||||||
|
const toggleVertical = (slug: string) => {
|
||||||
|
if (selected.includes(slug)) {
|
||||||
|
onChange(selected.filter(s => s !== slug))
|
||||||
|
} else {
|
||||||
|
onChange([...selected, slug])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
onChange([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
Bands
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary">
|
||||||
|
{selected.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Filter by Band</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{VERTICALS.map((vertical) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={vertical.slug}
|
||||||
|
checked={selected.includes(vertical.slug)}
|
||||||
|
onCheckedChange={() => toggleVertical(vertical.slug)}
|
||||||
|
>
|
||||||
|
{vertical.name}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={false}
|
||||||
|
onCheckedChange={clearFilters}
|
||||||
|
className="text-red-500 focus:text-red-500"
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Inline filters for desktop - optionally could show these next to dropdown or instead of on large screens */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 ml-2">
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="h-8 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
frontend/components/shows/date-grouped-list.tsx
Normal file
124
frontend/components/shows/date-grouped-list.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Calendar, MapPin, Youtube, Music2 } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
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 DateGroupedListProps {
|
||||||
|
shows: Show[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateGroupedList({ shows }: DateGroupedListProps) {
|
||||||
|
if (shows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No shows found matching your filters.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group shows by date
|
||||||
|
const groupedShows: Record<string, Show[]> = {}
|
||||||
|
|
||||||
|
shows.forEach(show => {
|
||||||
|
// Use local date string to avoid timezone shifts if possible, or usually just split T
|
||||||
|
const dateKey = new Date(show.date).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!groupedShows[dateKey]) {
|
||||||
|
groupedShows[dateKey] = []
|
||||||
|
}
|
||||||
|
groupedShows[dateKey].push(show)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort dates (descending) - assuming API returned sorted, but safe to re-sort keys if list order isn't guaranteed
|
||||||
|
// Actually relying on input order is safer if we trust the API to sort by date desc.
|
||||||
|
// Let's just iterate over the unique keys in the order they appear (which will be desc if input is desc)
|
||||||
|
const uniqueDates = Array.from(new Set(shows.map(s =>
|
||||||
|
new Date(s.date).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{uniqueDates.map(dateStr => (
|
||||||
|
<div key={dateStr} className="space-y-4">
|
||||||
|
<div className="sticky top-16 z-10 bg-background/95 backdrop-blur py-2 border-b">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-primary" />
|
||||||
|
{dateStr}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{groupedShows[dateStr].map(show => (
|
||||||
|
<Link key={show.id} href={`/shows/${show.slug}`} className="block group">
|
||||||
|
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50 relative overflow-hidden">
|
||||||
|
{/* Vertical Stripe/Badge */}
|
||||||
|
{show.vertical && (
|
||||||
|
<div className="absolute top-0 left-0 w-1 h-full bg-primary/50 group-hover:bg-primary transition-colors" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{show.youtube_link && (
|
||||||
|
<div className="absolute top-2 right-2 bg-red-500/10 text-red-500 p-1.5 rounded-full" title="Full show video available">
|
||||||
|
<Youtube className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardHeader className="pl-5 pb-2"> {/* Added padding-left for stripe */}
|
||||||
|
<CardTitle className="text-lg flex flex-col gap-1">
|
||||||
|
{show.vertical && (
|
||||||
|
<span className="text-xs font-medium text-primary uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Music2 className="h-3 w-3" />
|
||||||
|
{show.vertical.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="group-hover:text-primary transition-colors">
|
||||||
|
{show.venue?.name}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-5 pt-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
|
<MapPin className="h-3.5 w-3.5" />
|
||||||
|
<span>
|
||||||
|
{show.venue?.city}, {show.venue?.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue