- Fork elmeg-demo codebase for multi-band support - Add data importer infrastructure with base class - Create band-specific importers: - phish.py: Phish.net API v5 - grateful_dead.py: Grateful Stats API - setlistfm.py: Dead & Company, Billy Strings (Setlist.fm) - Add spec-kit configuration for Gemini - Update README with supported bands and architecture
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Target, CheckCircle, Trash2, Plus, Trophy, Music, Star } from "lucide-react"
|
|
import { getApiUrl } from "@/lib/api-config"
|
|
import Link from "next/link"
|
|
|
|
interface ChaseSong {
|
|
id: number
|
|
song_id: number
|
|
song_title: string
|
|
priority: number
|
|
notes: string | null
|
|
created_at: string
|
|
caught_at: string | null
|
|
caught_show_id: number | null
|
|
caught_show_date: string | null
|
|
}
|
|
|
|
interface ChaseSongsListProps {
|
|
userId?: number
|
|
}
|
|
|
|
export function ChaseSongsList({ userId }: ChaseSongsListProps) {
|
|
const [chaseSongs, setChaseSongs] = useState<ChaseSong[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [newSongQuery, setNewSongQuery] = useState("")
|
|
const [searchResults, setSearchResults] = useState<any[]>([])
|
|
const [showSearch, setShowSearch] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetchChaseSongs()
|
|
}, [])
|
|
|
|
const fetchChaseSongs = async () => {
|
|
const token = localStorage.getItem("token")
|
|
if (!token) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${getApiUrl()}/chase/songs`, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setChaseSongs(data)
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch chase songs", err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const searchSongs = async (query: string) => {
|
|
if (query.length < 2) {
|
|
setSearchResults([])
|
|
return
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${getApiUrl()}/search/songs?q=${encodeURIComponent(query)}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setSearchResults(data.slice(0, 5))
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to search songs", err)
|
|
}
|
|
}
|
|
|
|
const addChaseSong = async (songId: number) => {
|
|
const token = localStorage.getItem("token")
|
|
if (!token) return
|
|
|
|
try {
|
|
const res = await fetch(`${getApiUrl()}/chase/songs`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ song_id: songId, priority: 1 })
|
|
})
|
|
if (res.ok) {
|
|
fetchChaseSongs()
|
|
setNewSongQuery("")
|
|
setSearchResults([])
|
|
setShowSearch(false)
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to add chase song", err)
|
|
}
|
|
}
|
|
|
|
const removeChaseSong = async (chaseId: number) => {
|
|
const token = localStorage.getItem("token")
|
|
if (!token) return
|
|
|
|
try {
|
|
const res = await fetch(`${getApiUrl()}/chase/songs/${chaseId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
})
|
|
if (res.ok) {
|
|
setChaseSongs(chaseSongs.filter(cs => cs.id !== chaseId))
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to remove chase song", err)
|
|
}
|
|
}
|
|
|
|
const activeChaseSongs = chaseSongs.filter(cs => !cs.caught_at)
|
|
const caughtChaseSongs = chaseSongs.filter(cs => cs.caught_at)
|
|
|
|
const getPriorityLabel = (priority: number) => {
|
|
switch (priority) {
|
|
case 1: return { label: "Must See", color: "bg-red-500/10 text-red-500 border-red-500/30" }
|
|
case 2: return { label: "Want", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/30" }
|
|
default: return { label: "Nice", color: "bg-muted text-muted-foreground" }
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <div className="text-muted-foreground">Loading chase songs...</div>
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Target className="h-5 w-5 text-primary" />
|
|
Chase Songs
|
|
</CardTitle>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowSearch(!showSearch)}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Song
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{showSearch && (
|
|
<div className="space-y-2 p-4 bg-muted/50 rounded-lg">
|
|
<Input
|
|
placeholder="Search for a song to chase..."
|
|
value={newSongQuery}
|
|
onChange={(e) => {
|
|
setNewSongQuery(e.target.value)
|
|
searchSongs(e.target.value)
|
|
}}
|
|
/>
|
|
{searchResults.length > 0 && (
|
|
<div className="space-y-1">
|
|
{searchResults.map((song) => (
|
|
<div
|
|
key={song.id}
|
|
className="flex items-center justify-between p-2 rounded-md hover:bg-muted cursor-pointer"
|
|
onClick={() => addChaseSong(song.id)}
|
|
>
|
|
<span>{song.title}</span>
|
|
<Plus className="h-4 w-4 text-primary" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeChaseSongs.length === 0 && caughtChaseSongs.length === 0 ? (
|
|
<p className="text-muted-foreground text-center py-8">
|
|
No chase songs yet. Add songs you want to catch!
|
|
</p>
|
|
) : (
|
|
<>
|
|
{/* Active Chase Songs */}
|
|
{activeChaseSongs.length > 0 && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
|
Chasing ({activeChaseSongs.length})
|
|
</h4>
|
|
{activeChaseSongs.map((cs) => {
|
|
const priority = getPriorityLabel(cs.priority)
|
|
return (
|
|
<div
|
|
key={cs.id}
|
|
className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-muted/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Music className="h-4 w-4 text-muted-foreground" />
|
|
<Link
|
|
href={`/songs/${cs.song_id}`}
|
|
className="font-medium hover:text-primary transition-colors"
|
|
>
|
|
{cs.song_title}
|
|
</Link>
|
|
<Badge variant="outline" className={priority.color}>
|
|
{priority.label}
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeChaseSong(cs.id)}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Caught Songs */}
|
|
{caughtChaseSongs.length > 0 && (
|
|
<div className="space-y-2 pt-4">
|
|
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
Caught ({caughtChaseSongs.length})
|
|
</h4>
|
|
{caughtChaseSongs.map((cs) => (
|
|
<div
|
|
key={cs.id}
|
|
className="flex items-center justify-between p-3 rounded-lg border bg-green-500/5 border-green-500/20"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Trophy className="h-4 w-4 text-green-500" />
|
|
<Link
|
|
href={`/songs/${cs.song_id}`}
|
|
className="font-medium hover:text-primary transition-colors"
|
|
>
|
|
{cs.song_title}
|
|
</Link>
|
|
{cs.caught_show_date && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{new Date(cs.caught_show_date).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|