feat: add Sequences feature for song groupings
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-24 13:37:27 -08:00
parent 9b04ae51ff
commit 2df93a75e4
5 changed files with 608 additions and 2 deletions

View file

@ -1,6 +1,6 @@
from fastapi import FastAPI
import os
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences
from fastapi.middleware.cors import CORSMiddleware
@ -43,6 +43,7 @@ app.include_router(chase.router)
app.include_router(gamification.router)
app.include_router(videos.router)
app.include_router(musicians.router)
app.include_router(sequences.router)
# Optional features - can be disabled via env vars

View file

@ -170,6 +170,26 @@ class Song(SQLModel, table=True):
vertical: Vertical = Relationship(back_populates="songs")
class Sequence(SQLModel, table=True):
"""Named groupings of consecutive songs, e.g. 'Autumn Crossing' = Travelers > Elmeg the Wise"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="Human-readable name like 'Autumn Crossing'")
slug: str = Field(unique=True, index=True)
description: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None)
# Relationship to songs that make up this sequence
songs: List["SequenceSong"] = Relationship(back_populates="sequence")
class SequenceSong(SQLModel, table=True):
"""Join table linking songs to sequences with ordering"""
id: Optional[int] = Field(default=None, primary_key=True)
sequence_id: int = Field(foreign_key="sequence.id")
song_id: int = Field(foreign_key="song.id")
position: int = Field(description="Order in sequence, 1-indexed")
sequence: Sequence = Relationship(back_populates="songs")
class Tag(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True)

View file

@ -0,0 +1,213 @@
"""
Sequences Router - API endpoints for managing song sequences.
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from pydantic import BaseModel
from database import get_session
from models import Sequence, SequenceSong, Song
from slugify import generate_slug as slugify
from auth import get_current_user
from models import User
router = APIRouter(prefix="/sequences", tags=["sequences"])
# --- Schemas ---
class SequenceRead(BaseModel):
id: int
name: str
slug: str
description: Optional[str] = None
notes: Optional[str] = None
songs: List[dict] = [] # [{position, song_id, song_title}]
class SequenceCreate(BaseModel):
name: str
description: Optional[str] = None
notes: Optional[str] = None
song_ids: List[int] = [] # Ordered list of song IDs
class SequenceUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
notes: Optional[str] = None
song_ids: Optional[List[int]] = None
# --- Public Endpoints ---
@router.get("", response_model=List[SequenceRead])
def list_sequences(
search: Optional[str] = None,
limit: int = 100,
session: Session = Depends(get_session)
):
"""List all sequences"""
query = select(Sequence)
if search:
query = query.where(Sequence.name.icontains(search))
query = query.order_by(Sequence.name).limit(limit)
sequences = session.exec(query).all()
result = []
for seq in sequences:
# Get songs for this sequence
songs_query = select(SequenceSong).where(SequenceSong.sequence_id == seq.id).order_by(SequenceSong.position)
seq_songs = session.exec(songs_query).all()
song_list = []
for ss in seq_songs:
song = session.get(Song, ss.song_id)
song_list.append({
"position": ss.position,
"song_id": ss.song_id,
"song_title": song.title if song else "Unknown"
})
result.append({
"id": seq.id,
"name": seq.name,
"slug": seq.slug,
"description": seq.description,
"notes": seq.notes,
"songs": song_list
})
return result
@router.get("/{slug}")
def get_sequence(slug: str, session: Session = Depends(get_session)):
"""Get sequence details with songs"""
sequence = session.exec(select(Sequence).where(Sequence.slug == slug)).first()
if not sequence:
raise HTTPException(status_code=404, detail="Sequence not found")
# Get songs
songs_query = select(SequenceSong).where(SequenceSong.sequence_id == sequence.id).order_by(SequenceSong.position)
seq_songs = session.exec(songs_query).all()
song_list = []
for ss in seq_songs:
song = session.get(Song, ss.song_id)
song_list.append({
"position": ss.position,
"song_id": ss.song_id,
"song_title": song.title if song else "Unknown",
"song_slug": song.slug if song else None
})
return {
"id": sequence.id,
"name": sequence.name,
"slug": sequence.slug,
"description": sequence.description,
"notes": sequence.notes,
"songs": song_list
}
# --- Admin Endpoints ---
@router.post("", response_model=SequenceRead)
def create_sequence(
data: SequenceCreate,
session: Session = Depends(get_session),
user: User = Depends(get_current_user)
):
"""Create a new sequence"""
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
slug = slugify(data.name)
# Check for existing
existing = session.exec(select(Sequence).where(Sequence.slug == slug)).first()
if existing:
raise HTTPException(status_code=400, detail="Sequence with this name already exists")
sequence = Sequence(
name=data.name,
slug=slug,
description=data.description,
notes=data.notes
)
session.add(sequence)
session.commit()
session.refresh(sequence)
# Add songs
for i, song_id in enumerate(data.song_ids, start=1):
ss = SequenceSong(sequence_id=sequence.id, song_id=song_id, position=i)
session.add(ss)
session.commit()
return {
"id": sequence.id,
"name": sequence.name,
"slug": sequence.slug,
"description": sequence.description,
"notes": sequence.notes,
"songs": [{"position": i+1, "song_id": sid, "song_title": ""} for i, sid in enumerate(data.song_ids)]
}
@router.patch("/{sequence_id}")
def update_sequence(
sequence_id: int,
data: SequenceUpdate,
session: Session = Depends(get_session),
user: User = Depends(get_current_user)
):
"""Update a sequence"""
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
sequence = session.get(Sequence, sequence_id)
if not sequence:
raise HTTPException(status_code=404, detail="Sequence not found")
if data.name is not None:
sequence.name = data.name
sequence.slug = slugify(data.name)
if data.description is not None:
sequence.description = data.description
if data.notes is not None:
sequence.notes = data.notes
session.add(sequence)
# Update songs if provided
if data.song_ids is not None:
# Delete existing
existing = session.exec(select(SequenceSong).where(SequenceSong.sequence_id == sequence_id)).all()
for e in existing:
session.delete(e)
# Add new
for i, song_id in enumerate(data.song_ids, start=1):
ss = SequenceSong(sequence_id=sequence_id, song_id=song_id, position=i)
session.add(ss)
session.commit()
return {"message": "Sequence updated", "id": sequence_id}
@router.delete("/{sequence_id}")
def delete_sequence(
sequence_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user)
):
"""Delete a sequence"""
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
sequence = session.get(Sequence, sequence_id)
if not sequence:
raise HTTPException(status_code=404, detail="Sequence not found")
# Delete songs first
existing = session.exec(select(SequenceSong).where(SequenceSong.sequence_id == sequence_id)).all()
for e in existing:
session.delete(e)
session.delete(sequence)
session.commit()
return {"message": "Sequence deleted"}

View file

@ -3,7 +3,7 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { LayoutDashboard, MessageSquare, ShieldAlert, Users, Mic2, Calendar, Music2, MapPin, UserCircle } from "lucide-react"
import { LayoutDashboard, MessageSquare, ShieldAlert, Users, Mic2, Calendar, Music2, MapPin, UserCircle, Layers } from "lucide-react"
export default function AdminLayout({
children,
@ -38,6 +38,11 @@ export default function AdminLayout({
href: "/admin/songs",
icon: Music2
},
{
title: "Sequences",
href: "/admin/sequences",
icon: Layers
},
{
title: "Venues",
href: "/admin/venues",

View file

@ -0,0 +1,367 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Save, X, Layers, Plus, Trash2, GripVertical } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
interface SequenceSong {
position: number
song_id: number
song_title: string
}
interface Sequence {
id: number
name: string
slug: string
description: string | null
notes: string | null
songs: SequenceSong[]
}
interface Song {
id: number
title: string
slug: string
}
export default function AdminSequencesPage() {
const { user, token, loading: authLoading } = useAuth()
const router = useRouter()
const [sequences, setSequences] = useState<Sequence[]>([])
const [allSongs, setAllSongs] = useState<Song[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingSequence, setEditingSequence] = useState<Sequence | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [newSequence, setNewSequence] = useState({ name: "", description: "", notes: "", song_ids: [] as number[] })
const [saving, setSaving] = useState(false)
const [songSearch, setSongSearch] = useState("")
const fetchSequences = useCallback(async () => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/sequences?limit=1000`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) setSequences(await res.json())
} catch (e) {
console.error("Failed to fetch sequences", e)
} finally {
setLoading(false)
}
}, [token])
const fetchSongs = useCallback(async () => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/songs?limit=1000`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setAllSongs(data.songs || data)
}
} catch (e) {
console.error("Failed to fetch songs", e)
}
}, [token])
useEffect(() => {
if (authLoading) return
if (!user) {
router.push("/login")
return
}
if (user.role !== "admin") {
router.push("/")
return
}
fetchSequences()
fetchSongs()
}, [user, router, authLoading, fetchSequences, fetchSongs])
const createSequence = async () => {
if (!token || !newSequence.name) return
setSaving(true)
try {
const res = await fetch(`${getApiUrl()}/sequences`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(newSequence)
})
if (res.ok) {
fetchSequences()
setIsCreating(false)
setNewSequence({ name: "", description: "", notes: "", song_ids: [] })
}
} catch (e) {
console.error("Failed to create sequence", e)
} finally {
setSaving(false)
}
}
const deleteSequence = async (id: number) => {
if (!token) return
if (!confirm("Delete this sequence?")) return
try {
await fetch(`${getApiUrl()}/sequences/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
})
fetchSequences()
} catch (e) {
console.error("Failed to delete sequence", e)
}
}
const addSongToNew = (songId: number) => {
if (!newSequence.song_ids.includes(songId)) {
setNewSequence({ ...newSequence, song_ids: [...newSequence.song_ids, songId] })
}
}
const removeSongFromNew = (songId: number) => {
setNewSequence({ ...newSequence, song_ids: newSequence.song_ids.filter(id => id !== songId) })
}
const filteredSequences = sequences.filter(s =>
s.name.toLowerCase().includes(search.toLowerCase())
)
const filteredSongs = allSongs.filter(s =>
s.title.toLowerCase().includes(songSearch.toLowerCase())
).slice(0, 20)
if (loading) {
return (
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="grid gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-muted rounded animate-pulse" />)}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Layers className="h-6 w-6" />
Sequence Management
</h2>
<Button onClick={() => setIsCreating(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Sequence
</Button>
</div>
<p className="text-muted-foreground text-sm">
Sequences are named groupings of consecutive songs, like &quot;Autumn Crossing&quot; (Travelers &gt; Elmeg the Wise).
</p>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search sequences..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Sequence Name</th>
<th className="text-left p-3 font-medium">Songs</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredSequences.map(seq => (
<tr key={seq.id} className="border-t">
<td className="p-3">
<p className="font-medium">{seq.name}</p>
{seq.description && (
<p className="text-sm text-muted-foreground">{seq.description}</p>
)}
</td>
<td className="p-3">
<div className="flex flex-wrap gap-1">
{seq.songs.map((s, i) => (
<span key={s.song_id} className="text-sm">
{s.song_title}
{i < seq.songs.length - 1 && <span className="text-muted-foreground mx-1">&gt;</span>}
</span>
))}
{seq.songs.length === 0 && (
<Badge variant="outline" className="text-yellow-600 border-yellow-600">No songs</Badge>
)}
</div>
</td>
<td className="p-3 text-right space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingSequence(seq)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
onClick={() => deleteSequence(seq.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{filteredSequences.length === 0 && (
<div className="p-8 text-center text-muted-foreground">
No sequences found. Create one to get started!
</div>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={isCreating} onOpenChange={setIsCreating}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Sequence</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Sequence Name *</Label>
<Input
placeholder="e.g., Autumn Crossing"
value={newSequence.name}
onChange={(e) => setNewSequence({ ...newSequence, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Brief description of this sequence..."
value={newSequence.description}
onChange={(e) => setNewSequence({ ...newSequence, description: e.target.value })}
rows={2}
/>
</div>
<div className="space-y-2">
<Label>Songs in Sequence (in order)</Label>
<div className="border rounded-md p-3 min-h-[60px] bg-muted/20">
{newSequence.song_ids.length === 0 ? (
<p className="text-sm text-muted-foreground">No songs added yet. Search and add songs below.</p>
) : (
<div className="flex flex-wrap gap-2">
{newSequence.song_ids.map((id, i) => {
const song = allSongs.find(s => s.id === id)
return (
<Badge key={id} variant="secondary" className="flex items-center gap-1">
<GripVertical className="h-3 w-3 text-muted-foreground" />
{i + 1}. {song?.title || `Song ${id}`}
<button onClick={() => removeSongFromNew(id)} className="ml-1 hover:text-red-600">
<X className="h-3 w-3" />
</button>
</Badge>
)
})}
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label>Search Songs to Add</Label>
<Input
placeholder="Type to search songs..."
value={songSearch}
onChange={(e) => setSongSearch(e.target.value)}
/>
{songSearch && (
<div className="border rounded-md max-h-48 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"
onClick={() => addSongToNew(song.id)}
>
{song.title}
</button>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreating(false)}>
Cancel
</Button>
<Button onClick={createSequence} disabled={saving || !newSequence.name}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Creating..." : "Create Sequence"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog (simplified for now) */}
<Dialog open={!!editingSequence} onOpenChange={() => setEditingSequence(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit: {editingSequence?.name}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-muted-foreground mb-4">
Songs: {editingSequence?.songs.map(s => s.song_title).join(" > ") || "None"}
</p>
<p className="text-sm text-muted-foreground">
Full edit functionality coming soon. For now, delete and recreate to modify.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingSequence(null)}>
<X className="h-4 w-4 mr-2" />
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}