Compare commits

..

2 commits

Author SHA1 Message Date
fullsizemalt
d7acbebc9c feat: Sequences edit dialog with song reordering and management
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-24 16:20:29 -08:00
fullsizemalt
be57110de8 feat: Tour pages - add show counts, year grouping, stats card 2025-12-24 16:18:45 -08:00
4 changed files with 281 additions and 62 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

@ -40,6 +40,127 @@ 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()
@ -340,26 +461,24 @@ export default function AdminSequencesPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Edit Dialog (simplified for now) */} {/* Edit Dialog */}
<Dialog open={!!editingSequence} onOpenChange={() => setEditingSequence(null)}> <Dialog open={!!editingSequence} onOpenChange={() => setEditingSequence(null)}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit: {editingSequence?.name}</DialogTitle> <DialogTitle>Edit Sequence</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-4"> {editingSequence && (
<p className="text-muted-foreground mb-4"> <EditSequenceForm
Songs: {editingSequence?.songs.map(s => s.song_title).join(" > ") || "None"} sequence={editingSequence}
</p> allSongs={allSongs}
<p className="text-sm text-muted-foreground"> token={token!}
Full edit functionality coming soon. For now, delete and recreate to modify. onSave={() => {
</p> fetchSequences()
</div> setEditingSequence(null)
<DialogFooter> }}
<Button variant="outline" onClick={() => setEditingSequence(null)}> onCancel={() => setEditingSequence(null)}
<X className="h-4 w-4 mr-2" /> />
Close )}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>

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>
) )
} }