feat(profile): Polish My Dashboard and add public User Profile page
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
97a7f50c93
commit
8093cb4e45
3 changed files with 254 additions and 55 deletions
|
|
@ -13,6 +13,14 @@ class UserProfileUpdate(BaseModel):
|
||||||
bio: Optional[str] = None
|
bio: Optional[str] = None
|
||||||
avatar: Optional[str] = None
|
avatar: Optional[str] = None
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserRead)
|
||||||
|
def get_user_public(user_id: int, session: Session = Depends(get_session)):
|
||||||
|
"""Get public user profile"""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
@router.patch("/me", response_model=UserRead)
|
@router.patch("/me", response_model=UserRead)
|
||||||
def update_my_profile(
|
def update_my_profile(
|
||||||
update: UserProfileUpdate,
|
update: UserProfileUpdate,
|
||||||
|
|
|
||||||
158
frontend/app/profile/[id]/page.tsx
Normal file
158
frontend/app/profile/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"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, User, ArrowLeft } 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
username: 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 PublicProfilePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const [id, setId] = useState<string | null>(null)
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
|
const [badges, setBadges] = useState<UserBadge[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [stats, setStats] = useState({ attendance_count: 0, review_count: 0, group_count: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
params.then(p => setId(p.id))
|
||||||
|
}, [params])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// Public fetch - no auth header needed strictly, but maybe good practice if protected
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
|
||||||
|
const userRes = await fetch(`${getApiUrl()}/users/${id}`, { headers })
|
||||||
|
if (!userRes.ok) throw new Error("User not found")
|
||||||
|
const userData = await userRes.json()
|
||||||
|
setUser(userData)
|
||||||
|
|
||||||
|
// Fetch Stats
|
||||||
|
const statsRes = await fetch(`${getApiUrl()}/users/${id}/stats`, { headers })
|
||||||
|
if (statsRes.ok) setStats(await statsRes.json())
|
||||||
|
|
||||||
|
// Fetch Badges
|
||||||
|
// Check if badges endpoint exists for specific user, otherwise /badges/me is for me.
|
||||||
|
// Assuming we default to empty badges for public profile if no specific endpoint
|
||||||
|
// Actually, let's skip badges for now or try specific endpoint if it existed.
|
||||||
|
// Assuming no public badges endpoint yet.
|
||||||
|
setBadges([])
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
if (loading) return <div className="container py-20 text-center">Loading profile...</div>
|
||||||
|
if (error || !user) return <div className="container py-20 text-center">User not found</div>
|
||||||
|
|
||||||
|
const displayName = user.username || user.email.split('@')[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container py-10 max-w-5xl space-y-8">
|
||||||
|
<Link href="/leaderboards">
|
||||||
|
<Button variant="ghost" size="sm" className="mb-4">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Leaderboards
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Card className="border-0 shadow-none bg-transparent">
|
||||||
|
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||||
|
<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>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">{displayName}</h1>
|
||||||
|
<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).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{user.bio && (
|
||||||
|
<p className="max-w-xl text-lg text-muted-foreground/80">
|
||||||
|
{user.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<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">Shows</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px bg-border h-10" />
|
||||||
|
<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">Reviews</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px bg-border h-10" />
|
||||||
|
<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">Groups</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs defaultValue="attendance" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3 mb-8 h-12">
|
||||||
|
<TabsTrigger value="attendance" className="text-base">Attendance</TabsTrigger>
|
||||||
|
<TabsTrigger value="reviews" className="text-base">Reviews</TabsTrigger>
|
||||||
|
<TabsTrigger value="groups" className="text-base">Communities</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="attendance" className="space-y-6">
|
||||||
|
<UserAttendanceList userId={user.id} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="reviews" className="mt-6">
|
||||||
|
<UserReviewsList userId={user.id} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="groups" className="mt-6">
|
||||||
|
<UserGroupsList userId={user.id} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Trophy, Calendar } from "lucide-react"
|
import { Trophy, Calendar, Settings, User, Edit } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BadgeList } from "@/components/profile/badge-list"
|
import { BadgeList } from "@/components/profile/badge-list"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
@ -11,12 +11,16 @@ 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
id: number
|
id: number
|
||||||
email: string
|
email: string
|
||||||
username: string // Assuming we add this to UserRead or fetch separately
|
username: string
|
||||||
|
avatar: string | null
|
||||||
|
bio: string | null
|
||||||
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserBadge {
|
interface UserBadge {
|
||||||
|
|
@ -74,96 +78,125 @@ export default function ProfilePage() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="p-8 text-center">Loading profile...</div>
|
return <div className="container py-20 text-center">Loading profile...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
<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>
|
<h1 className="text-2xl font-bold">Please Log In</h1>
|
||||||
<Link href="/login">
|
<p className="text-muted-foreground">You need to be logged in to view your dashboard.</p>
|
||||||
<Button>Log In</Button>
|
<div className="flex gap-4">
|
||||||
</Link>
|
<Link href="/login">
|
||||||
|
<Button>Log In</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/register">
|
||||||
|
<Button variant="outline">Sign Up</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform badges for the list component
|
// Transform badges for the list component
|
||||||
const displayBadges = badges.map(b => b.badge)
|
const displayBadges = badges.map(b => b.badge)
|
||||||
|
const displayName = user.username || user.email.split('@')[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="container py-10 max-w-5xl space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
{/* Header Section */}
|
||||||
<div>
|
<div className="relative">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{user.email}</h1>
|
<div className="absolute right-0 top-0">
|
||||||
<p className="text-muted-foreground flex items-center gap-2">
|
<Link href="/settings">
|
||||||
<Calendar className="h-4 w-4" />
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
Member
|
<Settings className="h-4 w-4" />
|
||||||
</p>
|
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>
|
</div>
|
||||||
<Link href="/settings">
|
|
||||||
<Button variant="outline">Settings</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="w-full">
|
<Tabs defaultValue="overview" className="w-full">
|
||||||
<TabsList>
|
<TabsList className="grid w-full grid-cols-4 mb-8 h-12">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview" className="text-base font-medium">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="attendance">Attendance</TabsTrigger>
|
<TabsTrigger value="attendance" className="text-base font-medium">My Shows</TabsTrigger>
|
||||||
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
<TabsTrigger value="reviews" className="text-base font-medium">Reviews</TabsTrigger>
|
||||||
<TabsTrigger value="groups">Groups</TabsTrigger>
|
<TabsTrigger value="groups" className="text-base font-medium">Communities</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
<TabsContent value="overview" className="space-y-6">
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Shows Attended</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.attendance_count}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Reviews Written</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.review_count}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Groups Joined</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.group_count}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||||
Badges
|
Achievements
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<BadgeList badges={displayBadges} />
|
<BadgeList badges={displayBadges} />
|
||||||
|
{displayBadges.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">No badges earned yet. Keep active!</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Activity or generic stats summary could go here later */}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="attendance" className="mt-6">
|
<TabsContent value="attendance">
|
||||||
<UserAttendanceList userId={user.id} />
|
<UserAttendanceList userId={user.id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reviews" className="mt-6">
|
<TabsContent value="reviews">
|
||||||
<UserReviewsList userId={user.id} />
|
<UserReviewsList userId={user.id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="groups" className="mt-6">
|
<TabsContent value="groups">
|
||||||
<UserGroupsList userId={user.id} />
|
<UserGroupsList userId={user.id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue