Compare commits

..

No commits in common. "d7acbebc9ce5ebe2032fa01a3fa092d8f117883c" and "97d40c0f4ef7c14f00a591846d1c69296907dc91" have entirely different histories.

4 changed files with 62 additions and 281 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, func from sqlmodel import Session, select
from database import get_session from database import get_session
from models import Tour, Show, User from models import Tour, 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,46 +20,18 @@ def create_tour(
session.refresh(db_tour) session.refresh(db_tour)
return db_tour return db_tour
@router.get("/") @router.get("/", response_model=List[TourRead])
def read_tours( def read_tours(
offset: int = 0, offset: int = 0,
limit: int = Query(default=100, le=500), limit: int = Query(default=100, le=100),
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
# Get show counts per tour @router.get("/{slug}", response_model=TourRead)
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

@ -40,127 +40,6 @@ interface Song {
slug: string slug: string
} }
// Edit Sequence Form Component
function EditSequenceForm({
sequence,
allSongs,
token,
onSave,
onCancel
}: {
sequence: Sequence
allSongs: Song[]
token: string
onSave: () => void
onCancel: () => void
}) {
const [name, setName] = useState(sequence.name)
const [description, setDescription] = useState(sequence.description || "")
const [notes, setNotes] = useState(sequence.notes || "")
const [songIds, setSongIds] = useState<number[]>(sequence.songs.map(s => s.song_id))
const [songSearch, setSongSearch] = useState("")
const [saving, setSaving] = useState(false)
const filteredSongs = allSongs.filter(s =>
s.title.toLowerCase().includes(songSearch.toLowerCase()) &&
!songIds.includes(s.id)
).slice(0, 15)
const addSong = (id: number) => setSongIds([...songIds, id])
const removeSong = (id: number) => setSongIds(songIds.filter(sid => sid !== id))
const moveSong = (index: number, direction: -1 | 1) => {
const newIds = [...songIds]
const newIndex = index + direction
if (newIndex < 0 || newIndex >= newIds.length) return
[newIds[index], newIds[newIndex]] = [newIds[newIndex], newIds[index]]
setSongIds(newIds)
}
const handleSave = async () => {
setSaving(true)
try {
const res = await fetch(`${getApiUrl()}/sequences/${sequence.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ name, description, notes, song_ids: songIds })
})
if (res.ok) onSave()
} catch (e) {
console.error("Failed to save", e)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Name *</Label>
<Input value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} />
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2} placeholder="Internal notes..." />
</div>
<div className="space-y-2">
<Label>Songs (in order)</Label>
<div className="border rounded-md p-3 min-h-[60px] bg-muted/20 space-y-1">
{songIds.length === 0 ? (
<p className="text-sm text-muted-foreground">No songs. Add below.</p>
) : (
songIds.map((id, i) => {
const song = allSongs.find(s => s.id === id)
return (
<div key={id} className="flex items-center justify-between bg-background p-2 rounded border">
<span className="text-sm">{i + 1}. {song?.title || `Song ${id}`}</span>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => moveSong(i, -1)} disabled={i === 0}>
</Button>
<Button variant="ghost" size="sm" onClick={() => moveSong(i, 1)} disabled={i === songIds.length - 1}>
</Button>
<Button variant="ghost" size="sm" onClick={() => removeSong(id)} className="text-red-600">
<X className="h-4 w-4" />
</Button>
</div>
</div>
)
})
)}
</div>
</div>
<div className="space-y-2">
<Label>Add Songs</Label>
<Input placeholder="Search songs..." value={songSearch} onChange={e => setSongSearch(e.target.value)} />
{songSearch && (
<div className="border rounded-md max-h-40 overflow-y-auto">
{filteredSongs.map(song => (
<button key={song.id} className="w-full text-left px-3 py-2 hover:bg-muted/50 border-b last:border-b-0 text-sm" onClick={() => addSong(song.id)}>
{song.title}
</button>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={handleSave} disabled={saving || !name}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</div>
)
}
export default function AdminSequencesPage() { export default function AdminSequencesPage() {
const { user, token, loading: authLoading } = useAuth() const { user, token, loading: authLoading } = useAuth()
const router = useRouter() const router = useRouter()
@ -461,24 +340,26 @@ export default function AdminSequencesPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Edit Dialog */} {/* Edit Dialog (simplified for now) */}
<Dialog open={!!editingSequence} onOpenChange={() => setEditingSequence(null)}> <Dialog open={!!editingSequence} onOpenChange={() => setEditingSequence(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Sequence</DialogTitle> <DialogTitle>Edit: {editingSequence?.name}</DialogTitle>
</DialogHeader> </DialogHeader>
{editingSequence && ( <div className="py-4">
<EditSequenceForm <p className="text-muted-foreground mb-4">
sequence={editingSequence} Songs: {editingSequence?.songs.map(s => s.song_title).join(" > ") || "None"}
allSongs={allSongs} </p>
token={token!} <p className="text-sm text-muted-foreground">
onSave={() => { Full edit functionality coming soon. For now, delete and recreate to modify.
fetchSequences() </p>
setEditingSequence(null) </div>
}} <DialogFooter>
onCancel={() => setEditingSequence(null)} <Button variant="outline" onClick={() => setEditingSequence(null)}>
/> <X className="h-4 w-4 mr-2" />
)} Close
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>

View file

@ -1,7 +1,6 @@
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 { Badge } from "@/components/ui/badge" import { ArrowLeft, Calendar, Music2 } from "lucide-react"
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"
@ -10,17 +9,6 @@ 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' })
@ -53,15 +41,11 @@ 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="/tours"> <Link href="/archive">
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@ -70,8 +54,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("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} {tour.start_date ? new Date(tour.start_date).toLocaleDateString() : "Unknown"}
{tour.end_date && ` - ${new Date(tour.end_date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`} {tour.end_date && ` - ${new Date(tour.end_date).toLocaleDateString()}`}
</p> </p>
</div> </div>
</div> </div>
@ -90,19 +74,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: ShowWithVenue, b: ShowWithVenue) => new Date(a.date).getTime() - new Date(b.date).getTime()) .sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((show: ShowWithVenue) => ( .map((show: any) => (
<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-3 rounded-md hover:bg-muted/50 transition-colors border-b last:border-b-0"> <div className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors">
<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("en-US", { weekday: "short", month: "short", day: "numeric" })} {new Date(show.date).toLocaleDateString()}
</span> </span>
</div> </div>
{show.venue && ( {show.venue && (
<span className="text-sm text-muted-foreground"> <span className="text-xs text-muted-foreground">
{show.venue.name}, {show.venue.city}{show.venue.state ? `, ${show.venue.state}` : ""} {show.venue.name}, {show.venue.city}
</span> </span>
)} )}
</div> </div>
@ -125,47 +109,18 @@ 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 className="pb-2"> <CardHeader>
<CardTitle className="text-base">Tour Stats</CardTitle> <CardTitle>Tour Details</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-2">
<div className="flex items-center justify-between">
<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>
</Card>
{/* Tour Notes */}
{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> <p className="text-sm text-muted-foreground italic">{tour.notes}</p>
)}
</CardContent> </CardContent>
</Card> </Card>
)}
</div> </div>
</div> </div>
</div> </div>
) )
} }

View file

@ -3,17 +3,14 @@
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, Calendar } from "lucide-react" import { Map } 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() {
@ -21,7 +18,7 @@ export default function ToursPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch(`${getApiUrl()}/tours/?limit=200`) fetch(`${getApiUrl()}/tours/?limit=100`)
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
// Sort by start date descending // Sort by start date descending
@ -34,16 +31,6 @@ 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 (
@ -55,30 +42,19 @@ 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">
{toursByYear[year].map((tour) => ( {tours.map((tour) => (
<Link key={tour.id} href={`/tours/${tour.slug || tour.id}`}> <Link key={tour.id} href={`/tours/${tour.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors"> <Card className="h-full hover:bg-accent/50 transition-colors">
<CardHeader className="pb-2"> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center gap-2">
<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 flex items-center gap-2"> <p className="text-sm text-muted-foreground">
<Calendar className="h-4 w-4" /> {new Date(tour.start_date).toLocaleDateString()} - {new Date(tour.end_date).toLocaleDateString()}
{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>
@ -86,8 +62,5 @@ export default function ToursPage() {
))} ))}
</div> </div>
</div> </div>
))}
</div>
) )
} }