elmeg-demo/frontend/components/profile/chase-songs-list.tsx
fullsizemalt 2e4e0b811d feat: User profile enhancements - chase songs and attendance stats
Backend:
- Add ChaseSong model for tracking songs users want to see
- New /chase router with CRUD for chase songs
- Profile stats endpoint with heady versions, debuts, etc.

Frontend:
- ChaseSongsList component with search, add, remove
- AttendanceSummary with auto-generated stats
- Updated profile page with new Overview tab content
2025-12-21 18:39:39 -08:00

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