elmeg-demo/frontend/app/profile/page.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

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