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 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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<span className="text-sm font-medium">Your Rating</span>
|
<div className="flex items-center gap-2">
|
||||||
{averageRating > 0 && (
|
<span className="text-sm font-medium">Your Rating</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{isHeady && (
|
||||||
Community avg: <span className="font-medium">{averageRating.toFixed(1)}</span>
|
<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">
|
||||||
</span>
|
<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>
|
</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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue