fediversion/frontend/components/ui/rating-input.tsx
fullsizemalt b4cddf41ea feat: Initialize Fediversion multi-band platform
- 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
2025-12-28 12:39:28 -08:00

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>
)
}