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
This commit is contained in:
parent
131bafa825
commit
2e4e0b811d
6 changed files with 833 additions and 1 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
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
|
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
|
||||||
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
|
@ -35,6 +35,7 @@ app.include_router(feed.router)
|
||||||
app.include_router(leaderboards.router)
|
app.include_router(leaderboards.router)
|
||||||
app.include_router(stats.router)
|
app.include_router(stats.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
app.include_router(chase.router)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
|
|
|
||||||
|
|
@ -304,3 +304,18 @@ class Reaction(SQLModel, table=True):
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
user: User = Relationship()
|
user: User = Relationship()
|
||||||
|
|
||||||
|
class ChaseSong(SQLModel, table=True):
|
||||||
|
"""Songs a user wants to see live (hasn't seen performed yet or wants to see again)"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id", index=True)
|
||||||
|
song_id: int = Field(foreign_key="song.id", index=True)
|
||||||
|
priority: int = Field(default=1, description="1=high, 2=medium, 3=low")
|
||||||
|
notes: Optional[str] = Field(default=None)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
caught_at: Optional[datetime] = Field(default=None, description="When they finally saw it")
|
||||||
|
caught_show_id: Optional[int] = Field(default=None, foreign_key="show.id")
|
||||||
|
|
||||||
|
user: User = Relationship()
|
||||||
|
song: "Song" = Relationship()
|
||||||
|
|
||||||
|
|
|
||||||
327
backend/routers/chase.py
Normal file
327
backend/routers/chase.py
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
"""
|
||||||
|
Chase Songs and Profile Stats Router
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from database import get_session
|
||||||
|
from models import ChaseSong, Song, Attendance, Show, Performance, Rating, User
|
||||||
|
from routers.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/chase", tags=["chase"])
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
class ChaseSongCreate(BaseModel):
|
||||||
|
song_id: int
|
||||||
|
priority: int = 1
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class ChaseSongResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
song_id: int
|
||||||
|
song_title: str
|
||||||
|
priority: int
|
||||||
|
notes: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
caught_at: Optional[datetime]
|
||||||
|
caught_show_id: Optional[int]
|
||||||
|
caught_show_date: Optional[str] = None
|
||||||
|
|
||||||
|
class ChaseSongUpdate(BaseModel):
|
||||||
|
priority: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class ProfileStats(BaseModel):
|
||||||
|
shows_attended: int
|
||||||
|
unique_songs_seen: int
|
||||||
|
debuts_witnessed: int
|
||||||
|
heady_versions_attended: int # Top 10 rated performances
|
||||||
|
top_10_performances: int
|
||||||
|
total_ratings: int
|
||||||
|
total_reviews: int
|
||||||
|
chase_songs_count: int
|
||||||
|
chase_songs_caught: int
|
||||||
|
most_seen_song: Optional[str] = None
|
||||||
|
most_seen_count: int = 0
|
||||||
|
|
||||||
|
# --- Routes ---
|
||||||
|
|
||||||
|
@router.get("/songs", response_model=List[ChaseSongResponse])
|
||||||
|
async def get_my_chase_songs(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get all chase songs for the current user"""
|
||||||
|
statement = (
|
||||||
|
select(ChaseSong)
|
||||||
|
.where(ChaseSong.user_id == current_user.id)
|
||||||
|
.order_by(ChaseSong.priority, ChaseSong.created_at.desc())
|
||||||
|
)
|
||||||
|
chase_songs = session.exec(statement).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for cs in chase_songs:
|
||||||
|
song = session.get(Song, cs.song_id)
|
||||||
|
caught_show_date = None
|
||||||
|
if cs.caught_show_id:
|
||||||
|
show = session.get(Show, cs.caught_show_id)
|
||||||
|
if show:
|
||||||
|
caught_show_date = show.date.strftime("%Y-%m-%d") if show.date else None
|
||||||
|
|
||||||
|
result.append(ChaseSongResponse(
|
||||||
|
id=cs.id,
|
||||||
|
song_id=cs.song_id,
|
||||||
|
song_title=song.title if song else "Unknown",
|
||||||
|
priority=cs.priority,
|
||||||
|
notes=cs.notes,
|
||||||
|
created_at=cs.created_at,
|
||||||
|
caught_at=cs.caught_at,
|
||||||
|
caught_show_id=cs.caught_show_id,
|
||||||
|
caught_show_date=caught_show_date
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.post("/songs", response_model=ChaseSongResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_chase_song(
|
||||||
|
data: ChaseSongCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Add a song to user's chase list"""
|
||||||
|
# Check if song exists
|
||||||
|
song = session.get(Song, data.song_id)
|
||||||
|
if not song:
|
||||||
|
raise HTTPException(status_code=404, detail="Song not found")
|
||||||
|
|
||||||
|
# Check if already chasing
|
||||||
|
existing = session.exec(
|
||||||
|
select(ChaseSong)
|
||||||
|
.where(ChaseSong.user_id == current_user.id, ChaseSong.song_id == data.song_id)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Song already in chase list")
|
||||||
|
|
||||||
|
chase_song = ChaseSong(
|
||||||
|
user_id=current_user.id,
|
||||||
|
song_id=data.song_id,
|
||||||
|
priority=data.priority,
|
||||||
|
notes=data.notes
|
||||||
|
)
|
||||||
|
session.add(chase_song)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(chase_song)
|
||||||
|
|
||||||
|
return ChaseSongResponse(
|
||||||
|
id=chase_song.id,
|
||||||
|
song_id=chase_song.song_id,
|
||||||
|
song_title=song.title,
|
||||||
|
priority=chase_song.priority,
|
||||||
|
notes=chase_song.notes,
|
||||||
|
created_at=chase_song.created_at,
|
||||||
|
caught_at=None,
|
||||||
|
caught_show_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.patch("/songs/{chase_id}", response_model=ChaseSongResponse)
|
||||||
|
async def update_chase_song(
|
||||||
|
chase_id: int,
|
||||||
|
data: ChaseSongUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Update a chase song"""
|
||||||
|
chase_song = session.get(ChaseSong, chase_id)
|
||||||
|
if not chase_song or chase_song.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Chase song not found")
|
||||||
|
|
||||||
|
if data.priority is not None:
|
||||||
|
chase_song.priority = data.priority
|
||||||
|
if data.notes is not None:
|
||||||
|
chase_song.notes = data.notes
|
||||||
|
|
||||||
|
session.add(chase_song)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(chase_song)
|
||||||
|
|
||||||
|
song = session.get(Song, chase_song.song_id)
|
||||||
|
return ChaseSongResponse(
|
||||||
|
id=chase_song.id,
|
||||||
|
song_id=chase_song.song_id,
|
||||||
|
song_title=song.title if song else "Unknown",
|
||||||
|
priority=chase_song.priority,
|
||||||
|
notes=chase_song.notes,
|
||||||
|
created_at=chase_song.created_at,
|
||||||
|
caught_at=chase_song.caught_at,
|
||||||
|
caught_show_id=chase_song.caught_show_id
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/songs/{chase_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_chase_song(
|
||||||
|
chase_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Remove a song from chase list"""
|
||||||
|
chase_song = session.get(ChaseSong, chase_id)
|
||||||
|
if not chase_song or chase_song.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Chase song not found")
|
||||||
|
|
||||||
|
session.delete(chase_song)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@router.post("/songs/{chase_id}/caught")
|
||||||
|
async def mark_song_caught(
|
||||||
|
chase_id: int,
|
||||||
|
show_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Mark a chase song as caught at a specific show"""
|
||||||
|
chase_song = session.get(ChaseSong, chase_id)
|
||||||
|
if not chase_song or chase_song.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Chase song not found")
|
||||||
|
|
||||||
|
show = session.get(Show, show_id)
|
||||||
|
if not show:
|
||||||
|
raise HTTPException(status_code=404, detail="Show not found")
|
||||||
|
|
||||||
|
chase_song.caught_at = datetime.utcnow()
|
||||||
|
chase_song.caught_show_id = show_id
|
||||||
|
session.add(chase_song)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"message": "Song marked as caught!"}
|
||||||
|
|
||||||
|
|
||||||
|
# Profile stats endpoint
|
||||||
|
@router.get("/profile/stats", response_model=ProfileStats)
|
||||||
|
async def get_profile_stats(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Get comprehensive profile stats for the current user"""
|
||||||
|
|
||||||
|
# Shows attended
|
||||||
|
shows_attended = session.exec(
|
||||||
|
select(func.count(Attendance.id))
|
||||||
|
.where(Attendance.user_id == current_user.id)
|
||||||
|
).one() or 0
|
||||||
|
|
||||||
|
# Get show IDs user attended
|
||||||
|
attended_show_ids = session.exec(
|
||||||
|
select(Attendance.show_id)
|
||||||
|
.where(Attendance.user_id == current_user.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Unique songs seen (performances at attended shows)
|
||||||
|
unique_songs_seen = session.exec(
|
||||||
|
select(func.count(func.distinct(Performance.song_id)))
|
||||||
|
.where(Performance.show_id.in_(attended_show_ids) if attended_show_ids else False)
|
||||||
|
).one() or 0
|
||||||
|
|
||||||
|
# Debuts witnessed (times_played = 1 at show they attended)
|
||||||
|
# This would require joining with song data - simplified for now
|
||||||
|
debuts_witnessed = 0
|
||||||
|
if attended_show_ids:
|
||||||
|
debuts_q = session.exec(
|
||||||
|
select(Performance)
|
||||||
|
.where(Performance.show_id.in_(attended_show_ids))
|
||||||
|
).all()
|
||||||
|
# Count performances where this was the debut
|
||||||
|
for perf in debuts_q:
|
||||||
|
# Check if this was the first performance of the song
|
||||||
|
earlier_perfs = session.exec(
|
||||||
|
select(func.count(Performance.id))
|
||||||
|
.join(Show, Performance.show_id == Show.id)
|
||||||
|
.where(Performance.song_id == perf.song_id)
|
||||||
|
.where(Show.date < session.get(Show, perf.show_id).date if session.get(Show, perf.show_id) else False)
|
||||||
|
).one()
|
||||||
|
if earlier_perfs == 0:
|
||||||
|
debuts_witnessed += 1
|
||||||
|
|
||||||
|
# Top performances attended (with avg rating >= 8.0)
|
||||||
|
top_performances_attended = 0
|
||||||
|
heady_versions_attended = 0
|
||||||
|
if attended_show_ids:
|
||||||
|
# Get average ratings for performances at attended shows
|
||||||
|
perf_ratings = session.exec(
|
||||||
|
select(
|
||||||
|
Rating.performance_id,
|
||||||
|
func.avg(Rating.score).label("avg_rating")
|
||||||
|
)
|
||||||
|
.where(Rating.performance_id.isnot(None))
|
||||||
|
.group_by(Rating.performance_id)
|
||||||
|
.having(func.avg(Rating.score) >= 8.0)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Filter to performances at attended shows
|
||||||
|
high_rated_perf_ids = [pr[0] for pr in perf_ratings]
|
||||||
|
if high_rated_perf_ids:
|
||||||
|
attended_high_rated = session.exec(
|
||||||
|
select(func.count(Performance.id))
|
||||||
|
.where(Performance.id.in_(high_rated_perf_ids))
|
||||||
|
.where(Performance.show_id.in_(attended_show_ids))
|
||||||
|
).one() or 0
|
||||||
|
top_performances_attended = attended_high_rated
|
||||||
|
heady_versions_attended = attended_high_rated
|
||||||
|
|
||||||
|
# Total ratings/reviews
|
||||||
|
total_ratings = session.exec(
|
||||||
|
select(func.count(Rating.id)).where(Rating.user_id == current_user.id)
|
||||||
|
).one() or 0
|
||||||
|
|
||||||
|
total_reviews = session.exec(
|
||||||
|
select(func.count()).select_from(session.exec(
|
||||||
|
select(1).where(Rating.user_id == current_user.id) # placeholder
|
||||||
|
).subquery())
|
||||||
|
).one() if False else 0 # Will fix this
|
||||||
|
|
||||||
|
# Chase songs
|
||||||
|
chase_count = session.exec(
|
||||||
|
select(func.count(ChaseSong.id)).where(ChaseSong.user_id == current_user.id)
|
||||||
|
).one() or 0
|
||||||
|
|
||||||
|
chase_caught = session.exec(
|
||||||
|
select(func.count(ChaseSong.id))
|
||||||
|
.where(ChaseSong.user_id == current_user.id)
|
||||||
|
.where(ChaseSong.caught_at.isnot(None))
|
||||||
|
).one() or 0
|
||||||
|
|
||||||
|
# Most seen song
|
||||||
|
most_seen_song = None
|
||||||
|
most_seen_count = 0
|
||||||
|
if attended_show_ids:
|
||||||
|
song_counts = session.exec(
|
||||||
|
select(
|
||||||
|
Performance.song_id,
|
||||||
|
func.count(Performance.id).label("count")
|
||||||
|
)
|
||||||
|
.where(Performance.show_id.in_(attended_show_ids))
|
||||||
|
.group_by(Performance.song_id)
|
||||||
|
.order_by(func.count(Performance.id).desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if song_counts:
|
||||||
|
song = session.get(Song, song_counts[0])
|
||||||
|
if song:
|
||||||
|
most_seen_song = song.title
|
||||||
|
most_seen_count = song_counts[1]
|
||||||
|
|
||||||
|
return ProfileStats(
|
||||||
|
shows_attended=shows_attended,
|
||||||
|
unique_songs_seen=unique_songs_seen,
|
||||||
|
debuts_witnessed=min(debuts_witnessed, 50), # Cap to prevent timeout
|
||||||
|
heady_versions_attended=heady_versions_attended,
|
||||||
|
top_10_performances=top_performances_attended,
|
||||||
|
total_ratings=total_ratings,
|
||||||
|
total_reviews=0, # TODO: implement
|
||||||
|
chase_songs_count=chase_count,
|
||||||
|
chase_songs_caught=chase_caught,
|
||||||
|
most_seen_song=most_seen_song,
|
||||||
|
most_seen_count=most_seen_count
|
||||||
|
)
|
||||||
|
|
@ -11,6 +11,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { UserAttendanceList } from "@/components/profile/user-attendance-list"
|
import { UserAttendanceList } from "@/components/profile/user-attendance-list"
|
||||||
import { UserReviewsList } from "@/components/profile/user-reviews-list"
|
import { UserReviewsList } from "@/components/profile/user-reviews-list"
|
||||||
import { UserGroupsList } from "@/components/profile/user-groups-list"
|
import { UserGroupsList } from "@/components/profile/user-groups-list"
|
||||||
|
import { ChaseSongsList } from "@/components/profile/chase-songs-list"
|
||||||
|
import { AttendanceSummary } from "@/components/profile/attendance-summary"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
|
@ -176,10 +178,29 @@ export default function ProfilePage() {
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-6">
|
<TabsContent value="overview" className="space-y-6">
|
||||||
|
{/* Attendance Summary */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -10 }}
|
initial={{ opacity: 0, x: -10 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<AttendanceSummary />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Chase Songs */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<ChaseSongsList />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Achievements */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
209
frontend/components/profile/attendance-summary.tsx
Normal file
209
frontend/components/profile/attendance-summary.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Sparkles, Music, Trophy, Star, Calendar, Target, Eye } from "lucide-react"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
interface ProfileStats {
|
||||||
|
shows_attended: number
|
||||||
|
unique_songs_seen: number
|
||||||
|
debuts_witnessed: number
|
||||||
|
heady_versions_attended: number
|
||||||
|
top_10_performances: number
|
||||||
|
total_ratings: number
|
||||||
|
total_reviews: number
|
||||||
|
chase_songs_count: number
|
||||||
|
chase_songs_caught: number
|
||||||
|
most_seen_song: string | null
|
||||||
|
most_seen_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendanceSummary() {
|
||||||
|
const [stats, setStats] = useState<ProfileStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/chase/profile/stats`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setStats(data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch profile stats", err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground">Loading stats...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats || stats.shows_attended === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-yellow-500" />
|
||||||
|
Attendance Summary
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground text-center py-4">
|
||||||
|
No shows marked as attended yet. Start adding shows to see your stats!
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the summary sentence
|
||||||
|
const highlights: string[] = []
|
||||||
|
|
||||||
|
if (stats.heady_versions_attended > 0) {
|
||||||
|
highlights.push(`${stats.heady_versions_attended} heady version${stats.heady_versions_attended !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
if (stats.top_10_performances > 0) {
|
||||||
|
highlights.push(`${stats.top_10_performances} top-rated performance${stats.top_10_performances !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
if (stats.debuts_witnessed > 0) {
|
||||||
|
highlights.push(`${stats.debuts_witnessed} debut${stats.debuts_witnessed !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-primary/10 via-primary/5 to-transparent">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-yellow-500" />
|
||||||
|
Your Attendance Story
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-6">
|
||||||
|
{/* Main Summary */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-lg leading-relaxed"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
You've attended <strong className="text-primary">{stats.shows_attended} shows</strong> and
|
||||||
|
seen <strong className="text-primary">{stats.unique_songs_seen} unique songs</strong>.
|
||||||
|
</p>
|
||||||
|
{highlights.length > 0 && (
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
In attendance for {highlights.join(", ")}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{stats.most_seen_song && (
|
||||||
|
<p className="mt-2">
|
||||||
|
Your most-seen song is <strong className="text-primary">{stats.most_seen_song}</strong> ({stats.most_seen_count} times).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="text-center p-4 rounded-lg bg-muted/50"
|
||||||
|
>
|
||||||
|
<Calendar className="h-6 w-6 mx-auto mb-2 text-primary" />
|
||||||
|
<div className="text-2xl font-bold">{stats.shows_attended}</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Shows</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="text-center p-4 rounded-lg bg-muted/50"
|
||||||
|
>
|
||||||
|
<Music className="h-6 w-6 mx-auto mb-2 text-green-500" />
|
||||||
|
<div className="text-2xl font-bold">{stats.unique_songs_seen}</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Songs Seen</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="text-center p-4 rounded-lg bg-yellow-500/10"
|
||||||
|
>
|
||||||
|
<Trophy className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
|
||||||
|
<div className="text-2xl font-bold">{stats.heady_versions_attended}</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Heady Versions</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
className="text-center p-4 rounded-lg bg-purple-500/10"
|
||||||
|
>
|
||||||
|
<Star className="h-6 w-6 mx-auto mb-2 text-purple-500" />
|
||||||
|
<div className="text-2xl font-bold">{stats.debuts_witnessed}</div>
|
||||||
|
<div className="text-xs text-muted-foreground uppercase tracking-wider">Debuts</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chase Songs Progress */}
|
||||||
|
{stats.chase_songs_count > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="p-4 rounded-lg border bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<Target className="h-4 w-4 text-primary" />
|
||||||
|
Chase Progress
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono">
|
||||||
|
{stats.chase_songs_caught}/{stats.chase_songs_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${(stats.chase_songs_caught / stats.chase_songs_count) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{stats.chase_songs_count - stats.chase_songs_caught} songs left to catch!
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity Stats */}
|
||||||
|
<div className="flex items-center justify-center gap-8 pt-4 border-t text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Star className="h-4 w-4" />
|
||||||
|
<span>{stats.total_ratings} ratings</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
<span>{stats.total_reviews} reviews</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
259
frontend/components/profile/chase-songs-list.tsx
Normal file
259
frontend/components/profile/chase-songs-list.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue