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 sqlmodel import Session, select, desc
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 datetime import datetime
@ -16,6 +16,56 @@ class FeedItem(BaseModel):
timestamp: datetime
data: Union[ReviewRead, AttendanceRead, GroupPostRead, dict]
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])
def get_global_feed(
@ -40,54 +90,39 @@ def get_global_feed(
feed_items = []
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(
type="review",
timestamp=r.created_at or datetime.utcnow(),
data=r,
user=user_data
user=get_user_display(session, r.user_id),
entity=get_entity_info(session, r)
))
for a in attendance:
user = session.get(User, a.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
show = session.get(Show, a.show_id) if a.show_id else None
entity_info = None
if show:
entity_info = {
"type": "show",
"slug": show.slug,
"title": show.date.strftime("%Y-%m-%d") if show.date else "Unknown",
}
feed_items.append(FeedItem(
type="attendance",
timestamp=a.created_at,
data=a,
user=user_data
user=get_user_display(session, a.user_id),
entity=entity_info
))
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(
type="post",
timestamp=p.created_at,
data=p,
user=user_data
user=get_user_display(session, p.user_id),
entity=None
))
# Sort by timestamp desc

View file

@ -161,6 +161,34 @@ def get_average_rating(
avg = session.exec(query).first()
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 ---
@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 Link from "next/link"
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 {
type: string
@ -14,8 +22,11 @@ interface FeedItem {
user: {
id: number
username: string
avatar?: string
display_name?: string | null
avatar_bg_color?: string
avatar_text?: string | null
}
entity?: EntityInfo | null
}
export function ActivityFeed() {
@ -44,6 +55,28 @@ export function ActivityFeed() {
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>
return (
@ -52,18 +85,55 @@ export function ActivityFeed() {
<Card key={idx}>
<CardContent className="pt-6">
<div className="flex items-start gap-4">
{item.type === "review" && <Star className="h-5 w-5 text-yellow-500 mt-0.5" />}
{item.type === "attendance" && <Calendar className="h-5 w-5 text-blue-500 mt-0.5" />}
{item.type === "post" && <MessageSquare className="h-5 w-5 text-green-500 mt-0.5" />}
<UserAvatar
bgColor={item.user.avatar_bg_color || "#0F4C81"}
text={item.user.avatar_text || undefined}
username={displayName(item.user)}
size="sm"
/>
<div className="flex-1 space-y-1">
<p className="text-sm">
<span className="font-medium">{item.user.username || "Anonymous"}</span>
{item.type === "review" && " reviewed a show"}
{item.type === "attendance" && " attended a show"}
<Link
href={`/profiles/${item.user.username}`}
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"}
</p>
{item.type === "review" && (
{item.type === "review" && item.data.blurb && (
<div className="text-sm text-muted-foreground italic">
"<WikiText text={item.data.blurb} />"
</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-xs text-muted-foreground">
{new Date(item.timestamp).toLocaleDateString()}
{new Date(item.timestamp).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p>
</div>
</div>

View file

@ -3,15 +3,28 @@
import { useState, useEffect } from "react"
import { RatingInput, RatingBadge } from "@/components/ui/rating-input"
import { getApiUrl } from "@/lib/api-config"
import { useAuth } from "@/contexts/auth-context"
import { Sparkles, TrendingUp } from "lucide-react"
interface EntityRatingProps {
entityType: "show" | "song" | "venue" | "tour" | "performance"
entityId: number
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) {
const [userRating, setUserRating] = useState(0)
export function EntityRating({
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 [loading, setLoading] = 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(data => setAverageRating(data || 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])
const handleRate = async (score: number) => {
const token = localStorage.getItem("token")
if (!token) {
const storedToken = localStorage.getItem("token")
if (!storedToken) {
alert("Please log in to rate.")
return
}
@ -40,7 +69,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
Authorization: `Bearer ${storedToken}`
},
body: JSON.stringify(body)
})
@ -66,9 +95,20 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
if (compact) {
return (
<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 && (
<RatingBadge value={averageRating} />
)}
{rank && (
<span className="text-xs text-muted-foreground">
#{rank}
</span>
)}
</div>
)
}
@ -76,16 +116,33 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
return (
<div className="border rounded-lg p-4 bg-card">
<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>
{averageRating > 0 && (
<span className="text-xs text-muted-foreground">
Community avg: <span className="font-medium">{averageRating.toFixed(1)}</span>
{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 Version
</span>
)}
</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
value={userRating}
value={userRating || 0}
onChange={handleRate}
showSlider={true}
/>
@ -95,9 +152,9 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
Submitting...
</p>
)}
{hasRated && !loading && (
{hasRated && !loading && userRating && (
<p className="text-xs text-green-600 mt-2">
Rating saved!
Your rating: {userRating.toFixed(1)}/10
</p>
)}
</div>