diff --git a/backend/main.py b/backend/main.py index 3faa1dc..d10c7fc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/models.py b/backend/models.py index 20cf777..7374830 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) diff --git a/backend/routers/sequences.py b/backend/routers/sequences.py new file mode 100644 index 0000000..09b10dd --- /dev/null +++ b/backend/routers/sequences.py @@ -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"} diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index 1d0b625..3ee41cb 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -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", diff --git a/frontend/app/admin/sequences/page.tsx b/frontend/app/admin/sequences/page.tsx new file mode 100644 index 0000000..de83915 --- /dev/null +++ b/frontend/app/admin/sequences/page.tsx @@ -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([]) + const [allSongs, setAllSongs] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [editingSequence, setEditingSequence] = useState(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 ( +
+
+
+ {[1, 2, 3].map(i =>
)} +
+
+ ) + } + + return ( +
+
+

+ + Sequence Management +

+ +
+ +

+ Sequences are named groupings of consecutive songs, like "Autumn Crossing" (Travelers > Elmeg the Wise). +

+ +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + + + + + + + + + + + + {filteredSequences.map(seq => ( + + + + + + ))} + +
Sequence NameSongsActions
+

{seq.name}

+ {seq.description && ( +

{seq.description}

+ )} +
+
+ {seq.songs.map((s, i) => ( + + {s.song_title} + {i < seq.songs.length - 1 && >} + + ))} + {seq.songs.length === 0 && ( + No songs + )} +
+
+ + +
+ {filteredSequences.length === 0 && ( +
+ No sequences found. Create one to get started! +
+ )} +
+
+ + {/* Create Dialog */} + + + + Create New Sequence + +
+
+ + setNewSequence({ ...newSequence, name: e.target.value })} + /> +
+ +
+ +