- 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
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef, useEffect } from "react"
|
|
import { Star } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface RatingInputProps {
|
|
value: number
|
|
onChange?: (value: number) => void
|
|
readonly?: boolean
|
|
className?: string
|
|
size?: "sm" | "md" | "lg"
|
|
showSlider?: boolean
|
|
}
|
|
|
|
export function RatingInput({
|
|
value,
|
|
onChange,
|
|
readonly = false,
|
|
className,
|
|
size = "md",
|
|
showSlider = true
|
|
}: RatingInputProps) {
|
|
const [localValue, setLocalValue] = useState(value)
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
setLocalValue(value)
|
|
}, [value])
|
|
|
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newValue = parseFloat(e.target.value)
|
|
setLocalValue(newValue)
|
|
onChange?.(newValue)
|
|
}
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const raw = e.target.value
|
|
if (raw === "") {
|
|
setLocalValue(0)
|
|
return
|
|
}
|
|
const newValue = parseFloat(raw)
|
|
if (!isNaN(newValue)) {
|
|
const clamped = Math.min(10, Math.max(1, newValue))
|
|
const rounded = Math.round(clamped * 10) / 10
|
|
setLocalValue(rounded)
|
|
}
|
|
}
|
|
|
|
const handleInputBlur = () => {
|
|
setIsEditing(false)
|
|
if (localValue > 0) {
|
|
onChange?.(localValue)
|
|
}
|
|
}
|
|
|
|
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
inputRef.current?.blur()
|
|
}
|
|
}
|
|
|
|
// Visual star representation (readonly display)
|
|
const renderStars = () => {
|
|
const stars = []
|
|
const fullStars = Math.floor(localValue)
|
|
const partialFill = (localValue - fullStars) * 100
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const isFull = i < fullStars
|
|
const isPartial = i === fullStars && partialFill > 0
|
|
|
|
stars.push(
|
|
<div key={i} className="relative">
|
|
<Star className={cn(
|
|
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
|
|
"fill-muted text-muted-foreground/50"
|
|
)} />
|
|
{(isFull || isPartial) && (
|
|
<div
|
|
className="absolute inset-0 overflow-hidden"
|
|
style={{
|
|
clipPath: `inset(0 ${isFull ? 0 : 100 - partialFill}% 0 0)`
|
|
}}
|
|
>
|
|
<Star className={cn(
|
|
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
|
|
"fill-yellow-500 text-yellow-500"
|
|
)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
return stars
|
|
}
|
|
|
|
if (readonly) {
|
|
return (
|
|
<div className={cn("flex items-center gap-1", className)}>
|
|
<div className="flex gap-0.5">
|
|
{renderStars()}
|
|
</div>
|
|
<span className="ml-1.5 text-sm font-medium">
|
|
{localValue > 0 ? localValue.toFixed(1) : "—"}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-2", className)}>
|
|
{/* Stars Display + Numeric Input */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex gap-0.5">
|
|
{renderStars()}
|
|
</div>
|
|
|
|
{/* Numeric Input */}
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
ref={inputRef}
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
step="0.1"
|
|
value={isEditing ? localValue || "" : localValue.toFixed(1)}
|
|
onChange={handleInputChange}
|
|
onFocus={() => setIsEditing(true)}
|
|
onBlur={handleInputBlur}
|
|
onKeyDown={handleInputKeyDown}
|
|
className={cn(
|
|
"w-14 h-8 px-2 text-center font-mono font-bold rounded-md",
|
|
"border bg-background text-foreground",
|
|
"focus:outline-none focus:ring-2 focus:ring-primary",
|
|
size === "lg" ? "text-lg" : "text-sm"
|
|
)}
|
|
/>
|
|
<span className="text-muted-foreground text-sm">/10</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Slider */}
|
|
{showSlider && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground w-4">1</span>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="10"
|
|
step="0.1"
|
|
value={localValue || 1}
|
|
onChange={handleSliderChange}
|
|
className={cn(
|
|
"flex-1 h-2 rounded-full appearance-none cursor-pointer",
|
|
"bg-gradient-to-r from-red-500 via-yellow-500 to-green-500",
|
|
"[&::-webkit-slider-thumb]:appearance-none",
|
|
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
|
|
"[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full",
|
|
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary",
|
|
"[&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:cursor-grab",
|
|
"[&::-webkit-slider-thumb]:active:cursor-grabbing"
|
|
)}
|
|
/>
|
|
<span className="text-xs text-muted-foreground w-4">10</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Compact version for inline use
|
|
export function RatingBadge({ value, className }: { value: number; className?: string }) {
|
|
const getColor = () => {
|
|
if (value >= 8) return "bg-green-500/10 text-green-600 border-green-500/20"
|
|
if (value >= 6) return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20"
|
|
return "bg-red-500/10 text-red-600 border-red-500/20"
|
|
}
|
|
|
|
return (
|
|
<span className={cn(
|
|
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-xs font-medium",
|
|
getColor(),
|
|
className
|
|
)}>
|
|
<Star className="h-3 w-3 fill-current" />
|
|
{value.toFixed(1)}
|
|
</span>
|
|
)
|
|
}
|