feat(shows): redesign global shows hub
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
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:
parent
c090a395dc
commit
c860075681
5 changed files with 339 additions and 332 deletions
|
|
@ -91,11 +91,13 @@ 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())
|
||||||
# Default sort by date descending so we get recent shows first
|
else:
|
||||||
query = query.order_by(Show.date.desc())
|
# Default sort by date descending so we get recent shows first
|
||||||
|
query = query.order_by(Show.date.desc())
|
||||||
|
|
||||||
shows = session.exec(query.offset(offset).limit(limit)).all()
|
shows = session.exec(query.offset(offset).limit(limit)).all()
|
||||||
return shows
|
return shows
|
||||||
|
|
|
||||||
|
|
@ -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(
|
|
||||||
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
|
results = session.exec(query).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
VerticalRead(
|
||||||
|
**v.model_dump(),
|
||||||
|
logo_url=v.logo_url,
|
||||||
|
show_count=count
|
||||||
|
)
|
||||||
|
for v, count in results
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SceneRead(BaseModel):
|
class SceneRead(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
if (activeView === "upcoming") {
|
||||||
|
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 {
|
||||||
|
// Recent (Default)
|
||||||
|
params.set("status", "past")
|
||||||
|
}
|
||||||
|
|
||||||
fetch(url)
|
fetch(`${getApiUrl()}/shows/?${params.toString()}`)
|
||||||
.then(res => res.json())
|
.then(res => {
|
||||||
.then(data => {
|
// If 401 (Unauthorized) for My Feed, we might get empty list or error
|
||||||
const sorted = data.sort((a: Show, b: Show) =>
|
if (res.status === 401 && activeView === "my-feed") {
|
||||||
activeView === "upcoming"
|
// Redirect to login or handle?
|
||||||
? new Date(a.date).getTime() - new Date(b.date).getTime()
|
// For now, shows API returns [] if anon try to filter by tiers.
|
||||||
: new Date(b.date).getTime() - new Date(a.date).getTime()
|
return []
|
||||||
)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
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(","))
|
||||||
|
|
||||||
return (
|
// Push state
|
||||||
<div className="container py-8 space-y-6 animate-in fade-in duration-500">
|
router.push(`${pathname}${params.toString() ? `?${params.toString()}` : ''}`)
|
||||||
{/* 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 */}
|
const handleTabChange = (val: string) => {
|
||||||
<Tabs value={activeView} onValueChange={handleViewChange} className="w-full">
|
updateUrl(val, selectedBands)
|
||||||
<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">
|
const handleToggleBand = (slug: string) => {
|
||||||
<Clock className="h-4 w-4 hidden sm:block" />
|
let newBands = [...selectedBands]
|
||||||
Recent
|
if (newBands.includes(slug)) {
|
||||||
</TabsTrigger>
|
newBands = newBands.filter(b => b !== slug)
|
||||||
<TabsTrigger value="bands" className="gap-2">
|
} else {
|
||||||
<Music2 className="h-4 w-4 hidden sm:block" />
|
newBands.push(slug)
|
||||||
By Band
|
}
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="upcoming" className="gap-2">
|
// If we are on "bands" tab and select a band, switch to "recent" to show results?
|
||||||
<Calendar className="h-4 w-4 hidden sm:block" />
|
// User plan says: "Clicking a band adds it to the active filter and switches to 'Recent' view."
|
||||||
Upcoming
|
if (activeView === "bands" && !selectedBands.includes(slug)) {
|
||||||
</TabsTrigger>
|
updateUrl("recent", newBands)
|
||||||
<TabsTrigger value="feed" className="gap-2">
|
} else {
|
||||||
<Heart className="h-4 w-4 hidden sm:block" />
|
// Otherwise just update filters (e.g. if unchecking, stay on grid? or if adding from elsewhere?)
|
||||||
My Feed
|
// If checking from grid -> go to recent.
|
||||||
</TabsTrigger>
|
// If unchecking -> stay?
|
||||||
</TabsList>
|
// 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>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* Band Filter Pills - Only show on Recent and Upcoming */}
|
if (shows.length === 0) {
|
||||||
{(activeView === "recent" || activeView === "upcoming") && (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 pt-4 border-t mt-4">
|
<div className="text-center py-20 text-muted-foreground">
|
||||||
<span className="text-sm text-muted-foreground mr-2">Filter:</span>
|
<Music2 className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||||
|
<p>No shows found matching your criteria.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{selectedBands.length === 0 ? (
|
return <DateGroupedList shows={shows} />
|
||||||
<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 (
|
return (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="container py-6 max-w-5xl">
|
||||||
{verticals.map(vertical => (
|
<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">
|
||||||
<Link key={vertical.id} href={`/${vertical.slug}/shows`}>
|
<Tabs value={activeView} onValueChange={handleTabChange} className="w-full">
|
||||||
<Card className="h-full transition-all hover:scale-[1.02] hover:shadow-lg hover:border-primary/50 cursor-pointer">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
|
||||||
<CardContent className="p-4">
|
<TabsList className="grid w-full md:w-auto grid-cols-4 h-11">
|
||||||
<div className="flex items-center gap-3">
|
<TabsTrigger value="recent" className="flex items-center gap-2">
|
||||||
<div className="p-2 rounded-lg bg-primary/10">
|
Recent
|
||||||
<Music2 className="h-5 w-5 text-primary" />
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="my-feed" className="flex items-center gap-2">
|
||||||
|
My Feed
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="upcoming" className="flex items-center gap-2">
|
||||||
|
Upcoming
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="bands" className="flex items-center gap-2">
|
||||||
|
By Band
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterPills
|
||||||
|
selectedBands={selectedBands}
|
||||||
|
verticals={verticals}
|
||||||
|
onRemove={handleRemoveBand}
|
||||||
|
onClear={handleClearBands}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-2 min-h-[500px]">
|
||||||
|
<TabsContent value="recent" className="m-0">
|
||||||
|
{renderShowList()}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<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="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>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
) : (
|
||||||
<h3 className="font-semibold truncate">{vertical.name}</h3>
|
<BandGrid
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
verticals={verticals}
|
||||||
{vertical.description?.slice(0, 50)}...
|
selectedBands={selectedBands}
|
||||||
</p>
|
onToggle={handleToggleBand} // Note: logic inside handleToggle moves to Recent
|
||||||
</div>
|
/>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</TabsContent>
|
||||||
</Card>
|
</div>
|
||||||
</Link>
|
</Tabs>
|
||||||
))}
|
|
||||||
</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>
|
||||||
</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() {
|
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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
66
frontend/components/shows/band-grid.tsx
Normal file
66
frontend/components/shows/band-grid.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
frontend/components/shows/filter-pills.tsx
Normal file
51
frontend/components/shows/filter-pills.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue