- Fork elmeg-demo codebase for multi-band support - Add data importer infrastructure with base class - Create band-specific importers: - phish.py: Phish.net API v5 - grateful_dead.py: Grateful Stats API - setlistfm.py: Dead & Company, Billy Strings (Setlist.fm) - Add spec-kit configuration for Gemini - Update README with supported bands and architecture
162 lines
5.8 KiB
TypeScript
162 lines
5.8 KiB
TypeScript
"use client"
|
|
|
|
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,
|
|
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)
|
|
|
|
useEffect(() => {
|
|
// Fetch average rating
|
|
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
|
|
.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 storedToken = localStorage.getItem("token")
|
|
if (!storedToken) {
|
|
alert("Please log in to rate.")
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
try {
|
|
const body: Record<string, unknown> = { score }
|
|
body[`${entityType}_id`] = entityId
|
|
|
|
const res = await fetch(`${getApiUrl()}/social/ratings`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${storedToken}`
|
|
},
|
|
body: JSON.stringify(body)
|
|
})
|
|
|
|
if (!res.ok) throw new Error("Failed to submit rating")
|
|
|
|
const data = await res.json()
|
|
setUserRating(data.score)
|
|
setHasRated(true)
|
|
|
|
// Re-fetch average
|
|
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
|
|
.then(res => res.ok ? res.json() : averageRating)
|
|
.then(setAverageRating)
|
|
} catch (err) {
|
|
console.error(err)
|
|
alert("Error submitting rating")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
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>
|
|
{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 || 0}
|
|
onChange={handleRate}
|
|
showSlider={true}
|
|
/>
|
|
|
|
{loading && (
|
|
<p className="text-xs text-muted-foreground mt-2 animate-pulse">
|
|
Submitting...
|
|
</p>
|
|
)}
|
|
{hasRated && !loading && userRating && (
|
|
<p className="text-xs text-green-600 mt-2">
|
|
Your rating: {userRating.toFixed(1)}/10
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|