feat: Tour pages - add show counts, year grouping, stats card

This commit is contained in:
fullsizemalt 2025-12-24 16:18:45 -08:00
parent 97d40c0f4e
commit be57110de8
3 changed files with 145 additions and 45 deletions

View file

@ -1,8 +1,8 @@
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select from sqlmodel import Session, select, func
from database import get_session from database import get_session
from models import Tour, User from models import Tour, Show, User
from schemas import TourCreate, TourRead, TourUpdate from schemas import TourCreate, TourRead, TourUpdate
from auth import get_current_user from auth import get_current_user
@ -20,18 +20,46 @@ def create_tour(
session.refresh(db_tour) session.refresh(db_tour)
return db_tour return db_tour
@router.get("/", response_model=List[TourRead]) @router.get("/")
def read_tours( def read_tours(
offset: int = 0, offset: int = 0,
limit: int = Query(default=100, le=100), limit: int = Query(default=100, le=500),
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
"""Get all tours with show counts"""
tours = session.exec(select(Tour).offset(offset).limit(limit)).all() tours = session.exec(select(Tour).offset(offset).limit(limit)).all()
return tours
@router.get("/{slug}", response_model=TourRead) # Get show counts per tour
result = []
for tour in tours:
show_count = session.exec(
select(func.count(Show.id)).where(Show.tour_id == tour.id)
).one()
tour_dict = tour.model_dump()
tour_dict["show_count"] = show_count
result.append(tour_dict)
return result
@router.get("/{slug}")
def read_tour(slug: str, session: Session = Depends(get_session)): def read_tour(slug: str, session: Session = Depends(get_session)):
"""Get tour by slug or ID"""
# Try as ID first (for backwards compatibility)
try:
tour_id = int(slug)
tour = session.get(Tour, tour_id)
except ValueError:
tour = session.exec(select(Tour).where(Tour.slug == slug)).first() tour = session.exec(select(Tour).where(Tour.slug == slug)).first()
if not tour: if not tour:
raise HTTPException(status_code=404, detail="Tour not found") raise HTTPException(status_code=404, detail="Tour not found")
return tour
# Add show count
show_count = session.exec(
select(func.count(Show.id)).where(Show.tour_id == tour.id)
).one()
tour_dict = tour.model_dump()
tour_dict["show_count"] = show_count
return tour_dict

View file

@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Calendar, Music2 } from "lucide-react" import { Badge } from "@/components/ui/badge"
import { ArrowLeft, Calendar, MapPin, Hash } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
@ -9,6 +10,17 @@ import { EntityRating } from "@/components/social/entity-rating"
import { EntityReviews } from "@/components/reviews/entity-reviews" import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper" import { SocialWrapper } from "@/components/social/social-wrapper"
interface ShowWithVenue {
id: number
slug: string
date: string
venue?: {
name: string
city?: string
state?: string
}
}
async function getTour(id: string) { async function getTour(id: string) {
try { try {
const res = await fetch(`${getApiUrl()}/tours/${id}`, { cache: 'no-store' }) const res = await fetch(`${getApiUrl()}/tours/${id}`, { cache: 'no-store' })
@ -41,11 +53,15 @@ export default async function TourDetailPage({ params }: { params: Promise<{ slu
const shows = await getTourShows(tour.id) const shows = await getTourShows(tour.id)
// Calculate stats
const uniqueStates = [...new Set(shows.filter((s: ShowWithVenue) => s.venue?.state).map((s: ShowWithVenue) => s.venue!.state))]
const uniqueCities = [...new Set(shows.filter((s: ShowWithVenue) => s.venue?.city).map((s: ShowWithVenue) => s.venue!.city))]
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-4 justify-between"> <div className="flex items-center gap-4 justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/archive"> <Link href="/tours">
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@ -54,8 +70,8 @@ export default async function TourDetailPage({ params }: { params: Promise<{ slu
<h1 className="text-3xl font-bold tracking-tight">{tour.name}</h1> <h1 className="text-3xl font-bold tracking-tight">{tour.name}</h1>
<p className="text-muted-foreground flex items-center gap-2"> <p className="text-muted-foreground flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
{tour.start_date ? new Date(tour.start_date).toLocaleDateString() : "Unknown"} {tour.start_date ? new Date(tour.start_date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
{tour.end_date && ` - ${new Date(tour.end_date).toLocaleDateString()}`} {tour.end_date && ` - ${new Date(tour.end_date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`}
</p> </p>
</div> </div>
</div> </div>
@ -74,19 +90,19 @@ export default async function TourDetailPage({ params }: { params: Promise<{ slu
{shows.length > 0 ? ( {shows.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{[...shows] {[...shows]
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime()) .sort((a: ShowWithVenue, b: ShowWithVenue) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((show: any) => ( .map((show: ShowWithVenue) => (
<Link key={show.id} href={`/shows/${show.slug}`} className="block group"> <Link key={show.id} href={`/shows/${show.slug}`} className="block group">
<div className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between p-3 rounded-md hover:bg-muted/50 transition-colors border-b last:border-b-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
<span className="font-medium group-hover:underline"> <span className="font-medium group-hover:underline">
{new Date(show.date).toLocaleDateString()} {new Date(show.date).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
</span> </span>
</div> </div>
{show.venue && ( {show.venue && (
<span className="text-xs text-muted-foreground"> <span className="text-sm text-muted-foreground">
{show.venue.name}, {show.venue.city} {show.venue.name}, {show.venue.city}{show.venue.state ? `, ${show.venue.state}` : ""}
</span> </span>
)} )}
</div> </div>
@ -109,18 +125,47 @@ export default async function TourDetailPage({ params }: { params: Promise<{ slu
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Tour Stats */}
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Tour Details</CardTitle> <CardTitle className="text-base">Tour Stats</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-3">
{tour.notes && ( <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground italic">{tour.notes}</p> <span className="text-sm text-muted-foreground flex items-center gap-2">
)} <Hash className="h-4 w-4" /> Shows
</span>
<Badge variant="secondary">{shows.length}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<MapPin className="h-4 w-4" /> Cities
</span>
<Badge variant="outline">{uniqueCities.length}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<MapPin className="h-4 w-4" /> States
</span>
<Badge variant="outline">{uniqueStates.length}</Badge>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Tour Notes */}
{tour.notes && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground italic">{tour.notes}</p>
</CardContent>
</Card>
)}
</div> </div>
</div> </div>
</div> </div>
) )
} }

View file

@ -3,14 +3,17 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import Link from "next/link" import Link from "next/link"
import { Map } from "lucide-react" import { Map, Calendar } from "lucide-react"
interface Tour { interface Tour {
id: number id: number
name: string name: string
slug: string
start_date: string start_date: string
end_date: string end_date: string
show_count: number
} }
export default function ToursPage() { export default function ToursPage() {
@ -18,7 +21,7 @@ export default function ToursPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch(`${getApiUrl()}/tours/?limit=100`) fetch(`${getApiUrl()}/tours/?limit=200`)
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
// Sort by start date descending // Sort by start date descending
@ -31,6 +34,16 @@ export default function ToursPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
// Group tours by year
const toursByYear = tours.reduce((acc, tour) => {
const year = new Date(tour.start_date).getFullYear()
if (!acc[year]) acc[year] = []
acc[year].push(tour)
return acc
}, {} as Record<number, Tour[]>)
const years = Object.keys(toursByYear).map(Number).sort((a, b) => b - a)
if (loading) return <div className="container py-10">Loading tours...</div> if (loading) return <div className="container py-10">Loading tours...</div>
return ( return (
@ -42,19 +55,30 @@ export default function ToursPage() {
</p> </p>
</div> </div>
{years.map(year => (
<div key={year} className="space-y-4">
<h2 className="text-xl font-semibold text-muted-foreground border-b pb-2">{year}</h2>
<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">
{tours.map((tour) => ( {toursByYear[year].map((tour) => (
<Link key={tour.id} href={`/tours/${tour.id}`}> <Link key={tour.id} href={`/tours/${tour.slug || tour.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors"> <Card className="h-full hover:bg-accent/50 transition-colors">
<CardHeader> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Map className="h-5 w-5 text-orange-500" /> <Map className="h-5 w-5 text-orange-500" />
{tour.name} {tour.name}
</span>
<Badge variant="secondary" className="ml-2">
{tour.show_count} shows
</Badge>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground flex items-center gap-2">
{new Date(tour.start_date).toLocaleDateString()} - {new Date(tour.end_date).toLocaleDateString()} <Calendar className="h-4 w-4" />
{new Date(tour.start_date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
{" - "}
{new Date(tour.end_date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -62,5 +86,8 @@ export default function ToursPage() {
))} ))}
</div> </div>
</div> </div>
))}
</div>
) )
} }