feat: Better decimal rating input with slider
- New RatingInput component with slider + numeric input - Visual stars show partial fill for decimals - Gradient slider (red → yellow → green) for intuitive scoring - RatingBadge component for compact display - Updated EntityRating to use new components
This commit is contained in:
parent
d443eabd69
commit
b973b9e270
2 changed files with 228 additions and 20 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { StarRating } from "@/components/ui/star-rating"
|
||||
import { RatingInput, RatingBadge } from "@/components/ui/rating-input"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
|
||||
interface EntityRatingProps {
|
||||
|
|
@ -14,16 +14,14 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
|||
const [userRating, setUserRating] = useState(0)
|
||||
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.json())
|
||||
.then(data => setAverageRating(data))
|
||||
.catch(err => console.error("Failed to fetch avg rating", err))
|
||||
|
||||
// Fetch user rating (if logged in)
|
||||
// TODO: Implement fetching user's existing rating
|
||||
.then(res => res.ok ? res.json() : 0)
|
||||
.then(data => setAverageRating(data || 0))
|
||||
.catch(() => setAverageRating(0))
|
||||
}, [entityType, entityId])
|
||||
|
||||
const handleRate = async (score: number) => {
|
||||
|
|
@ -35,7 +33,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
|||
|
||||
setLoading(true)
|
||||
try {
|
||||
const body: any = { score }
|
||||
const body: Record<string, unknown> = { score }
|
||||
body[`${entityType}_id`] = entityId
|
||||
|
||||
const res = await fetch(`${getApiUrl()}/social/ratings`, {
|
||||
|
|
@ -51,10 +49,11 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
|||
|
||||
const data = await res.json()
|
||||
setUserRating(data.score)
|
||||
setHasRated(true)
|
||||
|
||||
// Re-fetch average to keep it lively
|
||||
// Re-fetch average
|
||||
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
|
||||
.then(res => res.json())
|
||||
.then(res => res.ok ? res.json() : averageRating)
|
||||
.then(setAverageRating)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -66,24 +65,41 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
|||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 opacity-80 hover:opacity-100 transition-opacity">
|
||||
<StarRating value={userRating} onChange={handleRate} size="sm" />
|
||||
<div className="flex items-center gap-2">
|
||||
{averageRating > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{averageRating.toFixed(1)}
|
||||
</span>
|
||||
<RatingBadge value={averageRating} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-l pl-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Rating:</span>
|
||||
<span className="text-xs text-muted-foreground">Avg: {averageRating.toFixed(1)}</span>
|
||||
<div className="border rounded-lg p-4 bg-card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium">Your Rating</span>
|
||||
{averageRating > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Community avg: <span className="font-medium">{averageRating.toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StarRating value={userRating} onChange={handleRate} />
|
||||
|
||||
<RatingInput
|
||||
value={userRating}
|
||||
onChange={handleRate}
|
||||
showSlider={true}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<p className="text-xs text-muted-foreground mt-2 animate-pulse">
|
||||
Submitting...
|
||||
</p>
|
||||
)}
|
||||
{hasRated && !loading && (
|
||||
<p className="text-xs text-green-600 mt-2">
|
||||
✓ Rating saved!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
192
frontend/components/ui/rating-input.tsx
Normal file
192
frontend/components/ui/rating-input.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue