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
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Trophy, Calendar, Settings, User, Edit } from "lucide-react"
|
|
import Link from "next/link"
|
|
import { BadgeList } from "@/components/profile/badge-list"
|
|
import { getApiUrl } from "@/lib/api-config"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import { UserAttendanceList } from "@/components/profile/user-attendance-list"
|
|
import { UserReviewsList } from "@/components/profile/user-reviews-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 { motion } from "framer-motion"
|
|
|
|
// Types
|
|
interface UserProfile {
|
|
id: number
|
|
email: string
|
|
username: string
|
|
avatar: string | null
|
|
bio: string | null
|
|
created_at: string
|
|
}
|
|
|
|
interface UserBadge {
|
|
id: number
|
|
badge: {
|
|
id: number
|
|
name: string
|
|
description: string
|
|
icon: string
|
|
slug: string
|
|
}
|
|
awarded_at: string
|
|
}
|
|
|
|
export default function ProfilePage() {
|
|
const [user, setUser] = useState<UserProfile | null>(null)
|
|
const [badges, setBadges] = useState<UserBadge[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const [stats, setStats] = useState({ attendance_count: 0, review_count: 0, group_count: 0 })
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem("token")
|
|
if (!token) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
// Fetch User Data
|
|
fetch(`${getApiUrl()}/auth/users/me`, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
})
|
|
.then(res => {
|
|
if (res.ok) return res.json()
|
|
throw new Error("Failed to fetch user")
|
|
})
|
|
.then(data => {
|
|
setUser(data)
|
|
// Fetch Badges
|
|
fetch(`${getApiUrl()}/badges/me`, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
})
|
|
.then(res => res.ok ? res.json() : [])
|
|
.then(data => setBadges(data))
|
|
|
|
// Fetch Stats
|
|
fetch(`${getApiUrl()}/users/${data.id}/stats`, {
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
})
|
|
.then(res => res.ok ? res.json() : { attendance_count: 0, review_count: 0, group_count: 0 })
|
|
.then((statsData: { attendance_count: number; review_count: number; group_count: number }) => setStats(statsData))
|
|
})
|
|
.catch(err => console.error(err))
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
if (loading) {
|
|
return <div className="container py-20 text-center">Loading profile...</div>
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-4 py-20 min-h-[50vh]">
|
|
<h1 className="text-2xl font-bold">Please Log In</h1>
|
|
<p className="text-muted-foreground">You need to be logged in to view your dashboard.</p>
|
|
<div className="flex gap-4">
|
|
<Link href="/login">
|
|
<Button>Log In</Button>
|
|
</Link>
|
|
<Link href="/register">
|
|
<Button variant="outline">Sign Up</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Transform badges for the list component
|
|
const displayBadges = badges.map(b => b.badge)
|
|
const displayName = user.username || user.email.split('@')[0]
|
|
|
|
return (
|
|
<div className="container py-10 max-w-5xl space-y-8">
|
|
{/* Header Section */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
className="relative"
|
|
>
|
|
<div className="absolute right-0 top-0">
|
|
<Link href="/settings">
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Settings className="h-4 w-4" />
|
|
Settings
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row gap-8 items-start pt-8">
|
|
<Avatar className="h-32 w-32 border-4 border-background shadow-lg">
|
|
<AvatarImage src={`https://api.dicebear.com/7.x/notionists/svg?seed=${user.id}`} />
|
|
<AvatarFallback><User className="h-12 w-12" /></AvatarFallback>
|
|
</Avatar>
|
|
<div className="space-y-4 flex-1">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-4xl font-bold tracking-tight">{displayName}</h1>
|
|
{/* Optional: display role badge */}
|
|
</div>
|
|
<p className="text-muted-foreground flex items-center gap-2 mt-2">
|
|
<Calendar className="h-4 w-4" />
|
|
Member since {new Date(user.created_at || Date.now()).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
{user.bio ? (
|
|
<p className="max-w-xl text-lg text-muted-foreground/80">
|
|
{user.bio}
|
|
</p>
|
|
) : (
|
|
<Link href="/settings" className="text-sm text-muted-foreground hover:underline italic">
|
|
+ Add a bio to your profile
|
|
</Link>
|
|
)}
|
|
|
|
<div className="flex gap-6 py-2">
|
|
<div className="flex flex-col">
|
|
<span className="text-2xl font-bold">{stats.attendance_count}</span>
|
|
<span className="text-sm text-muted-foreground uppercase tracking-wider font-medium">Shows</span>
|
|
</div>
|
|
<div className="w-px bg-border h-10 self-center" />
|
|
<div className="flex flex-col">
|
|
<span className="text-2xl font-bold">{stats.review_count}</span>
|
|
<span className="text-sm text-muted-foreground uppercase tracking-wider font-medium">Reviews</span>
|
|
</div>
|
|
<div className="w-px bg-border h-10 self-center" />
|
|
<div className="flex flex-col">
|
|
<span className="text-2xl font-bold">{stats.group_count}</span>
|
|
<span className="text-sm text-muted-foreground uppercase tracking-wider font-medium">Groups</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<Tabs defaultValue="overview" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4 mb-8 h-12">
|
|
<TabsTrigger value="overview" className="text-base font-medium">Overview</TabsTrigger>
|
|
<TabsTrigger value="attendance" className="text-base font-medium">My Shows</TabsTrigger>
|
|
<TabsTrigger value="reviews" className="text-base font-medium">Reviews</TabsTrigger>
|
|
<TabsTrigger value="groups" className="text-base font-medium">Communities</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="overview" className="space-y-6">
|
|
{/* Attendance Summary */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
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>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Trophy className="h-5 w-5 text-yellow-500" />
|
|
Achievements
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<BadgeList badges={displayBadges} />
|
|
{displayBadges.length === 0 && (
|
|
<p className="text-muted-foreground text-sm">No badges earned yet. Keep active!</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="attendance">
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<UserAttendanceList userId={user.id} />
|
|
</motion.div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="reviews">
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<UserReviewsList userId={user.id} />
|
|
</motion.div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="groups">
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<UserGroupsList userId={user.id} />
|
|
</motion.div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|