From c8600756819347ac43f0f780fcde1f96a9b5e1cd Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:18:10 -0800 Subject: [PATCH] feat(shows): redesign global shows hub - 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 --- backend/routers/shows.py | 8 +- backend/routers/verticals.py | 42 +- frontend/app/shows/page.tsx | 504 ++++++++------------- frontend/components/shows/band-grid.tsx | 66 +++ frontend/components/shows/filter-pills.tsx | 51 +++ 5 files changed, 339 insertions(+), 332 deletions(-) create mode 100644 frontend/components/shows/band-grid.tsx create mode 100644 frontend/components/shows/filter-pills.tsx diff --git a/backend/routers/shows.py b/backend/routers/shows.py index ba15933..d15183a 100644 --- a/backend/routers/shows.py +++ b/backend/routers/shows.py @@ -91,11 +91,13 @@ def read_shows( today = datetime.now() if status == "past": query = query.where(Show.date <= today) + query = query.order_by(Show.date.desc()) elif status == "upcoming": query = query.where(Show.date > today) - - # Default sort by date descending so we get recent shows first - query = query.order_by(Show.date.desc()) + query = query.order_by(Show.date.asc()) + else: + # 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() return shows diff --git a/backend/routers/verticals.py b/backend/routers/verticals.py index 9d9a4d2..0e73d6b 100644 --- a/backend/routers/verticals.py +++ b/backend/routers/verticals.py @@ -15,6 +15,8 @@ class VerticalRead(BaseModel): name: str slug: str description: str | None = None + logo_url: str | None = None + show_count: int = 0 class UserVerticalPreferenceRead(BaseModel): @@ -54,30 +56,30 @@ def list_verticals( scene: str | None = None, session: Session = Depends(get_session) ): - """List all available verticals (bands), optionally filtered by scene""" - from models import Scene, VerticalScene + """List all available verticals (bands) with show counts""" + 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: - # Filter by 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") + query = query.join(VerticalScene).join(Scene).where(Scene.slug == scene) - vertical_ids = session.exec( - 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() + query = query.group_by(Vertical.id).order_by(Vertical.name) - 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): diff --git a/frontend/app/shows/page.tsx b/frontend/app/shows/page.tsx index 044bab8..a1440b9 100644 --- a/frontend/app/shows/page.tsx +++ b/frontend/app/shows/page.tsx @@ -1,17 +1,14 @@ "use client" -import { useEffect, useState, Suspense, useMemo } from "react" +import { useEffect, useState, Suspense } 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 { Loader2, Music2 } from "lucide-react" 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" +import { FilterPills } from "@/components/shows/filter-pills" +import { BandGrid } from "@/components/shows/band-grid" interface Show { id: number @@ -28,12 +25,15 @@ interface Show { city: string state: string } + performances?: any[] // Simplified } -interface VerticalWithCount { +interface Vertical { + id: number slug: string name: string - showCount?: number + show_count: number + logo_url?: string | null } function ShowsContent() { @@ -41,345 +41,231 @@ function ShowsContent() { const router = useRouter() const pathname = usePathname() - // Parse query params + // --- State --- + const activeView = searchParams.get("view") || "recent" const bandsParam = searchParams.get("bands") - const viewParam = searchParams.get("view") || "recent" - - const initialBands = bandsParam ? bandsParam.split(",") : [] + const selectedBands = bandsParam ? bandsParam.split(",") : [] const [shows, setShows] = useState([]) - const [upcomingShows, setUpcomingShows] = useState([]) + const [verticals, setVerticals] = useState([]) const [loading, setLoading] = useState(true) - const [selectedBands, setSelectedBands] = useState(initialBands) - const [activeView, setActiveView] = useState(viewParam) - const [bandCounts, setBandCounts] = useState>({}) + const [loadingVerticals, setLoadingVerticals] = useState(true) - // Fetch band counts on mount + // --- Data Fetching: Verticals (Always) --- useEffect(() => { + setLoadingVerticals(true) fetch(`${getApiUrl()}/verticals/`) - .then(res => res.json()) - .then(verticals => { - // We'll get counts from the shows data instead + .then(res => { + if (!res.ok) throw new Error("Failed to fetch verticals") + return res.json() + }) + .then(data => { + setVerticals(data) + setLoadingVerticals(false) + }) + .catch(err => { + console.error(err) + setLoadingVerticals(false) }) - .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 + // --- Data Fetching: Shows (Dependent on View) --- 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) 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) { - 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) - .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 = {} - 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) + 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 - 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]) + }, [activeView, bandsParam]) // bandsParam is the dependency - // Bands available for filtering - const availableBands = VERTICALS.filter(v => - activeBandsInData.includes(v.slug) || selectedBands.includes(v.slug) - ) + // --- Handlers --- + const updateUrl = (view: string, bands: string[]) => { + const params = new URLSearchParams() + if (view !== "recent") params.set("view", view) + if (bands.length > 0) params.set("bands", bands.join(",")) - return ( -
- {/* Header */} -
-

Shows

-

- Browse the complete archive across all bands. -

-
+ // Push state + router.push(`${pathname}${params.toString() ? `?${params.toString()}` : ''}`) + } - {/* Tabs Navigation */} - -
- - - - Recent - - - - By Band - - - - Upcoming - - - - My Feed - - + 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 ( +
+
+ ) + } - {/* Band Filter Pills - Only show on Recent and Upcoming */} - {(activeView === "recent" || activeView === "upcoming") && ( -
- Filter: + if (shows.length === 0) { + return ( +
+ +

No shows found matching your criteria.

+
+ ) + } - {selectedBands.length === 0 ? ( - - All Bands - - ) : ( - <> - {selectedBands.map(slug => { - const band = VERTICALS.find(v => v.slug === slug) - return ( - toggleBand(slug)} - > - {band?.name || slug} - - - ) - })} - - - )} - - {/* Add band buttons */} - {selectedBands.length === 0 && availableBands.length > 0 && ( -
- {availableBands.slice(0, 5).map(band => ( - toggleBand(band.slug)} - > - {band.name} - {bandCounts[band.slug] && ( - - ({bandCounts[band.slug]}) - - )} - - ))} - {availableBands.length > 5 && ( - - +{availableBands.length - 5} more - - )} -
- )} -
- )} - - {/* Tab Content */} - - {loading ? ( - - ) : ( - - )} - - - - - - - - {loading ? ( - - ) : upcomingShows.length === 0 ? ( -
- -

No upcoming shows announced

-

Check back later for new tour dates!

-
- ) : ( - - )} -
- - - - - -
- ) -} - -function BandsGrid() { - const [verticals, setVerticals] = useState([]) - 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 ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) + return } return ( -
- {verticals.map(vertical => ( - - - -
-
- +
+
+ +
+ + + Recent + + + My Feed + + + Upcoming + + + By Band + + +
+ + + +
+ + {renderShowList()} + + + + {/* Maybe add auth check banner here if shows is empty and user not logged in? */} + {/* For now, just render list */} + {renderShowList()} + + + + {renderShowList()} + + + + {loadingVerticals ? ( +
+
-
-

{vertical.name}

-

- {vertical.description?.slice(0, 50)}... -

-
-
- - - - ))} -
- ) -} - -function MyFeedPlaceholder() { - return ( -
- -

Your Personal Feed

-

- Follow your favorite bands to see a customized feed of shows tailored to your preferences. -

-
- - - - - - + ) : ( + + )} + +
+
) } -function LoadingSkeleton() { - return ( -
-
- -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
- ) -} - -function LoadingFallback() { - return ( -
- -
- ) -} - export default function ShowsPage() { return ( - }> +
}> ) diff --git a/frontend/components/shows/band-grid.tsx b/frontend/components/shows/band-grid.tsx new file mode 100644 index 0000000..d0f7954 --- /dev/null +++ b/frontend/components/shows/band-grid.tsx @@ -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 ( +
+ {verticals.map((v) => { + const isSelected = selectedBands.includes(v.slug) + return ( + onToggle(v.slug)} + > + + {isSelected && ( +
+ + + +
+ )} + +
+ {v.logo_url ? ( + {v.name} + ) : ( + + )} +
+ +
+

{v.name}

+

+ {v.show_count.toLocaleString()} shows +

+
+
+
+ ) + })} +
+ ) +} diff --git a/frontend/components/shows/filter-pills.tsx b/frontend/components/shows/filter-pills.tsx new file mode 100644 index 0000000..28b2c57 --- /dev/null +++ b/frontend/components/shows/filter-pills.tsx @@ -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 ( +
+ Filtering by: + {selectedBands.map(slug => { + const band = verticals.find(v => v.slug === slug) + return ( + + {band?.name || slug} + + + ) + })} + +
+ ) +}