- 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
125 lines
4 KiB
TypeScript
125 lines
4 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef } from "react"
|
|
import { Star } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface RatingProps {
|
|
value: number
|
|
onChange?: (value: number) => void
|
|
readonly?: boolean
|
|
className?: string
|
|
size?: "sm" | "md" | "lg"
|
|
precision?: "full" | "half" | "decimal"
|
|
}
|
|
|
|
export function StarRating({
|
|
value,
|
|
onChange,
|
|
readonly = false,
|
|
className,
|
|
size = "md",
|
|
precision = "decimal"
|
|
}: RatingProps) {
|
|
const [hoverValue, setHoverValue] = useState<number | null>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
const stars = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
|
const starSize = {
|
|
sm: "h-3 w-3",
|
|
md: "h-4 w-4",
|
|
lg: "h-5 w-5"
|
|
}[size]
|
|
|
|
const handleMouseMove = (e: React.MouseEvent, starIndex: number) => {
|
|
if (readonly) return
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
const x = e.clientX - rect.left
|
|
const width = rect.width
|
|
const fraction = x / width
|
|
|
|
let newValue: number
|
|
if (precision === "decimal") {
|
|
// Allow 0.1 precision (e.g., 9.2)
|
|
newValue = starIndex + Math.round(fraction * 10) / 10
|
|
newValue = Math.max(starIndex, Math.min(starIndex + 1, newValue))
|
|
} else if (precision === "half") {
|
|
// Half-star precision
|
|
newValue = fraction < 0.5 ? starIndex + 0.5 : starIndex + 1
|
|
} else {
|
|
// Full star only
|
|
newValue = starIndex + 1
|
|
}
|
|
|
|
setHoverValue(newValue)
|
|
}
|
|
|
|
const handleClick = () => {
|
|
if (!readonly && hoverValue !== null && onChange) {
|
|
// Round to 1 decimal place
|
|
onChange(Math.round(hoverValue * 10) / 10)
|
|
}
|
|
}
|
|
|
|
const displayValue = hoverValue !== null ? hoverValue : value
|
|
|
|
// Calculate fill percentage for each star
|
|
const getStarFill = (starIndex: number): number => {
|
|
const starStart = starIndex
|
|
const starEnd = starIndex + 1
|
|
|
|
if (displayValue >= starEnd) return 100
|
|
if (displayValue <= starStart) return 0
|
|
return (displayValue - starStart) * 100
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("flex gap-0.5 items-center", className)}
|
|
onMouseLeave={() => !readonly && setHoverValue(null)}
|
|
>
|
|
{stars.map((star, index) => {
|
|
const fillPercent = getStarFill(index)
|
|
|
|
return (
|
|
<button
|
|
key={star}
|
|
type="button"
|
|
disabled={readonly}
|
|
className={cn(
|
|
"p-0.5 transition-transform relative",
|
|
readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
|
|
)}
|
|
onMouseMove={(e) => handleMouseMove(e, index)}
|
|
onClick={handleClick}
|
|
>
|
|
{/* Background star (empty) */}
|
|
<Star
|
|
className={cn(starSize, "fill-muted text-muted-foreground")}
|
|
/>
|
|
{/* Foreground star (filled) - uses clip-path for partial fill */}
|
|
<div
|
|
className="absolute inset-0 p-0.5 overflow-hidden"
|
|
style={{
|
|
clipPath: `inset(0 ${100 - fillPercent}% 0 0)`
|
|
}}
|
|
>
|
|
<Star
|
|
className={cn(starSize, "fill-primary text-primary")}
|
|
/>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
|
|
{/* Display numeric value */}
|
|
{!readonly && (
|
|
<span className="ml-2 text-sm font-mono text-muted-foreground min-w-[2.5rem]">
|
|
{displayValue > 0 ? displayValue.toFixed(1) : "—"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|