Enhance ratings: show user's rating, fix username consistency, add activity links

This commit is contained in:
fullsizemalt 2025-12-26 19:20:17 -08:00
parent bbcb935685
commit 60b5cb9961
4 changed files with 247 additions and 53 deletions

View file

@ -2,7 +2,7 @@ from typing import List, Union
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select, desc from sqlmodel import Session, select, desc
from database import get_session from database import get_session
from models import Review, Attendance, GroupPost, User from models import Review, Attendance, GroupPost, User, Profile, Performance, Show, Song
from schemas import ReviewRead, AttendanceRead, GroupPostRead from schemas import ReviewRead, AttendanceRead, GroupPostRead
from datetime import datetime from datetime import datetime
@ -16,6 +16,56 @@ class FeedItem(BaseModel):
timestamp: datetime timestamp: datetime
data: Union[ReviewRead, AttendanceRead, GroupPostRead, dict] data: Union[ReviewRead, AttendanceRead, GroupPostRead, dict]
user: dict # Basic user info user: dict # Basic user info
entity: dict | None = None # Linked entity info
def get_user_display(session: Session, user_id: int) -> dict:
"""Get consistent user display info using Profile username"""
user = session.get(User, user_id)
if not user:
return {"id": 0, "username": "Deleted User", "avatar_bg_color": "#666", "avatar_text": None}
profile = session.exec(
select(Profile).where(Profile.user_id == user_id)
).first()
return {
"id": user.id,
"username": profile.username if profile else f"User {user_id}",
"display_name": profile.display_name if profile else None,
"avatar_bg_color": user.avatar_bg_color or "#0F4C81",
"avatar_text": user.avatar_text,
}
def get_entity_info(session: Session, review: Review) -> dict | None:
"""Get entity link info for a review"""
if review.performance_id:
perf = session.get(Performance, review.performance_id)
if perf:
song = session.get(Song, perf.song_id)
show = session.get(Show, perf.show_id)
return {
"type": "performance",
"slug": perf.slug,
"title": song.title if song else "Unknown Song",
"date": show.date.isoformat() if show and show.date else None,
}
elif review.show_id:
show = session.get(Show, review.show_id)
if show:
return {
"type": "show",
"slug": show.slug,
"title": show.date.strftime("%Y-%m-%d") if show.date else "Unknown Date",
}
elif review.song_id:
song = session.get(Song, review.song_id)
if song:
return {
"type": "song",
"slug": song.slug,
"title": song.title,
}
return None
@router.get("/", response_model=List[FeedItem]) @router.get("/", response_model=List[FeedItem])
def get_global_feed( def get_global_feed(
@ -40,54 +90,39 @@ def get_global_feed(
feed_items = [] feed_items = []
for r in reviews: for r in reviews:
user = session.get(User, r.user_id)
user_data = {"id": 0, "username": "Deleted User", "avatar": None}
if user:
user_data = {
"id": user.id,
"username": user.email.split("@")[0] if user.email else "User",
"avatar": user.avatar
}
feed_items.append(FeedItem( feed_items.append(FeedItem(
type="review", type="review",
timestamp=r.created_at or datetime.utcnow(), timestamp=r.created_at or datetime.utcnow(),
data=r, data=r,
user=user_data user=get_user_display(session, r.user_id),
entity=get_entity_info(session, r)
)) ))
for a in attendance: for a in attendance:
user = session.get(User, a.user_id) show = session.get(Show, a.show_id) if a.show_id else None
user_data = {"id": 0, "username": "Deleted User", "avatar": None} entity_info = None
if user: if show:
user_data = { entity_info = {
"id": user.id, "type": "show",
"username": user.email.split("@")[0] if user.email else "User", "slug": show.slug,
"avatar": user.avatar "title": show.date.strftime("%Y-%m-%d") if show.date else "Unknown",
} }
feed_items.append(FeedItem( feed_items.append(FeedItem(
type="attendance", type="attendance",
timestamp=a.created_at, timestamp=a.created_at,
data=a, data=a,
user=user_data user=get_user_display(session, a.user_id),
entity=entity_info
)) ))
for p in posts: for p in posts:
user = session.get(User, p.user_id)
user_data = {"id": 0, "username": "Deleted User", "avatar": None}
if user:
user_data = {
"id": user.id,
"username": user.email.split("@")[0] if user.email else "User",
"avatar": user.avatar
}
feed_items.append(FeedItem( feed_items.append(FeedItem(
type="post", type="post",
timestamp=p.created_at, timestamp=p.created_at,
data=p, data=p,
user=user_data user=get_user_display(session, p.user_id),
entity=None
)) ))
# Sort by timestamp desc # Sort by timestamp desc

View file

@ -161,6 +161,34 @@ def get_average_rating(
avg = session.exec(query).first() avg = session.exec(query).first()
return float(avg) if avg else 0.0 return float(avg) if avg else 0.0
@router.get("/ratings/me")
def get_my_rating(
show_id: Optional[int] = None,
song_id: Optional[int] = None,
performance_id: Optional[int] = None,
venue_id: Optional[int] = None,
tour_id: Optional[int] = None,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get current user's rating for an entity"""
query = select(Rating).where(Rating.user_id == current_user.id)
if show_id:
query = query.where(Rating.show_id == show_id)
elif song_id:
query = query.where(Rating.song_id == song_id)
elif performance_id:
query = query.where(Rating.performance_id == performance_id)
elif venue_id:
query = query.where(Rating.venue_id == venue_id)
elif tour_id:
query = query.where(Rating.tour_id == tour_id)
else:
return None
rating = session.exec(query).first()
return {"score": rating.score if rating else None, "id": rating.id if rating else None}
# --- Reactions --- # --- Reactions ---
@router.post("/reactions", response_model=ReactionRead) @router.post("/reactions", response_model=ReactionRead)

View file

@ -6,6 +6,14 @@ import { Card, CardContent } from "@/components/ui/card"
import { Calendar, MessageSquare, Star, Users } from "lucide-react" import { Calendar, MessageSquare, Star, Users } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { WikiText } from "@/components/ui/wiki-text" import { WikiText } from "@/components/ui/wiki-text"
import { UserAvatar } from "@/components/ui/user-avatar"
interface EntityInfo {
type: "performance" | "show" | "song" | "venue" | "tour"
slug: string
title: string
date?: string
}
interface FeedItem { interface FeedItem {
type: string type: string
@ -14,8 +22,11 @@ interface FeedItem {
user: { user: {
id: number id: number
username: string username: string
avatar?: string display_name?: string | null
avatar_bg_color?: string
avatar_text?: string | null
} }
entity?: EntityInfo | null
} }
export function ActivityFeed() { export function ActivityFeed() {
@ -44,6 +55,28 @@ export function ActivityFeed() {
fetchFeed() fetchFeed()
}, []) }, [])
const getEntityLink = (entity: EntityInfo | null | undefined) => {
if (!entity) return null
const basePath = entity.type === "performance" ? "/performances" :
entity.type === "show" ? "/shows" :
entity.type === "song" ? "/songs" :
entity.type === "venue" ? "/venues" : "/tours"
return `${basePath}/${entity.slug}`
}
const getEntityTypeLabel = (entity: EntityInfo | null | undefined) => {
if (!entity) return "something"
switch (entity.type) {
case "performance": return "a performance of"
case "show": return "the"
case "song": return ""
case "venue": return ""
default: return ""
}
}
const displayName = (user: FeedItem["user"]) => user.display_name || user.username
if (loading) return <div>Loading activity...</div> if (loading) return <div>Loading activity...</div>
return ( return (
@ -52,18 +85,55 @@ export function ActivityFeed() {
<Card key={idx}> <Card key={idx}>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{item.type === "review" && <Star className="h-5 w-5 text-yellow-500 mt-0.5" />} <UserAvatar
{item.type === "attendance" && <Calendar className="h-5 w-5 text-blue-500 mt-0.5" />} bgColor={item.user.avatar_bg_color || "#0F4C81"}
{item.type === "post" && <MessageSquare className="h-5 w-5 text-green-500 mt-0.5" />} text={item.user.avatar_text || undefined}
username={displayName(item.user)}
size="sm"
/>
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">{item.user.username || "Anonymous"}</span> <Link
{item.type === "review" && " reviewed a show"} href={`/profiles/${item.user.username}`}
{item.type === "attendance" && " attended a show"} className="font-medium hover:text-primary transition-colors"
>
{displayName(item.user)}
</Link>
{item.type === "review" && (
<>
{" reviewed "}
{item.entity ? (
<Link
href={getEntityLink(item.entity) || "#"}
className="font-medium text-primary hover:underline"
>
{getEntityTypeLabel(item.entity)} {item.entity.title}
{item.entity.date && ` (${new Date(item.entity.date).toLocaleDateString()})`}
</Link>
) : (
"a performance"
)}
</>
)}
{item.type === "attendance" && (
<>
{" attended "}
{item.entity ? (
<Link
href={getEntityLink(item.entity) || "#"}
className="font-medium text-primary hover:underline"
>
{item.entity.title}
</Link>
) : (
"a show"
)}
</>
)}
{item.type === "post" && " posted in a group"} {item.type === "post" && " posted in a group"}
</p> </p>
{item.type === "review" && ( {item.type === "review" && item.data.blurb && (
<div className="text-sm text-muted-foreground italic"> <div className="text-sm text-muted-foreground italic">
"<WikiText text={item.data.blurb} />" "<WikiText text={item.data.blurb} />"
</div> </div>
@ -72,7 +142,11 @@ export function ActivityFeed() {
<p className="text-sm text-muted-foreground line-clamp-2">{item.data.content}</p> <p className="text-sm text-muted-foreground line-clamp-2">{item.data.content}</p>
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{new Date(item.timestamp).toLocaleDateString()} {new Date(item.timestamp).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p> </p>
</div> </div>
</div> </div>

View file

@ -3,15 +3,28 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { RatingInput, RatingBadge } from "@/components/ui/rating-input" import { RatingInput, RatingBadge } from "@/components/ui/rating-input"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { useAuth } from "@/contexts/auth-context"
import { Sparkles, TrendingUp } from "lucide-react"
interface EntityRatingProps { interface EntityRatingProps {
entityType: "show" | "song" | "venue" | "tour" | "performance" entityType: "show" | "song" | "venue" | "tour" | "performance"
entityId: number entityId: number
compact?: boolean compact?: boolean
ratingCount?: number
rank?: number // Rank of this performance vs others
isHeady?: boolean // Is this a top-rated "heady" version
} }
export function EntityRating({ entityType, entityId, compact = false }: EntityRatingProps) { export function EntityRating({
const [userRating, setUserRating] = useState(0) entityType,
entityId,
compact = false,
ratingCount,
rank,
isHeady = false
}: EntityRatingProps) {
const { user, token } = useAuth()
const [userRating, setUserRating] = useState<number | null>(null)
const [averageRating, setAverageRating] = useState(0) const [averageRating, setAverageRating] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [hasRated, setHasRated] = useState(false) const [hasRated, setHasRated] = useState(false)
@ -22,11 +35,27 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
.then(res => res.ok ? res.json() : 0) .then(res => res.ok ? res.json() : 0)
.then(data => setAverageRating(data || 0)) .then(data => setAverageRating(data || 0))
.catch(() => setAverageRating(0)) .catch(() => setAverageRating(0))
// Fetch user's rating if logged in
const storedToken = localStorage.getItem("token")
if (storedToken) {
fetch(`${getApiUrl()}/social/ratings/me?${entityType}_id=${entityId}`, {
headers: { Authorization: `Bearer ${storedToken}` }
})
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.score) {
setUserRating(data.score)
setHasRated(true)
}
})
.catch(() => { })
}
}, [entityType, entityId]) }, [entityType, entityId])
const handleRate = async (score: number) => { const handleRate = async (score: number) => {
const token = localStorage.getItem("token") const storedToken = localStorage.getItem("token")
if (!token) { if (!storedToken) {
alert("Please log in to rate.") alert("Please log in to rate.")
return return
} }
@ -40,7 +69,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}` Authorization: `Bearer ${storedToken}`
}, },
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
@ -66,9 +95,20 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
if (compact) { if (compact) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isHeady && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 border border-yellow-500/20 text-xs font-medium">
<Sparkles className="h-3 w-3" />
Heady
</span>
)}
{averageRating > 0 && ( {averageRating > 0 && (
<RatingBadge value={averageRating} /> <RatingBadge value={averageRating} />
)} )}
{rank && (
<span className="text-xs text-muted-foreground">
#{rank}
</span>
)}
</div> </div>
) )
} }
@ -76,16 +116,33 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
return ( return (
<div className="border rounded-lg p-4 bg-card"> <div className="border rounded-lg p-4 bg-card">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Your Rating</span> <span className="text-sm font-medium">Your Rating</span>
{averageRating > 0 && ( {isHeady && (
<span className="text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 border border-yellow-500/20 text-xs font-medium">
Community avg: <span className="font-medium">{averageRating.toFixed(1)}</span> <Sparkles className="h-3 w-3" />
Heady Version
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-3">
{rank && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<TrendingUp className="h-3 w-3" />
Ranked #{rank}
</span>
)}
{averageRating > 0 && (
<span className="text-xs text-muted-foreground">
Community: <span className="font-medium text-foreground">{averageRating.toFixed(1)}</span>
{ratingCount && ` (${ratingCount})`}
</span>
)}
</div>
</div>
<RatingInput <RatingInput
value={userRating} value={userRating || 0}
onChange={handleRate} onChange={handleRate}
showSlider={true} showSlider={true}
/> />
@ -95,9 +152,9 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
Submitting... Submitting...
</p> </p>
)} )}
{hasRated && !loading && ( {hasRated && !loading && userRating && (
<p className="text-xs text-green-600 mt-2"> <p className="text-xs text-green-600 mt-2">
Rating saved! Your rating: {userRating.toFixed(1)}/10
</p> </p>
)} )}
</div> </div>