Enhance ratings: show user's rating, fix username consistency, add activity links
This commit is contained in:
parent
bbcb935685
commit
60b5cb9961
4 changed files with 247 additions and 53 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue