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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
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"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
interface EntityRatingProps {
|
interface EntityRatingProps {
|
||||||
|
|
@ -14,16 +14,14 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
||||||
const [userRating, setUserRating] = useState(0)
|
const [userRating, setUserRating] = useState(0)
|
||||||
const [averageRating, setAverageRating] = useState(0)
|
const [averageRating, setAverageRating] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [hasRated, setHasRated] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch average rating
|
// Fetch average rating
|
||||||
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
|
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
|
||||||
.then(res => res.json())
|
.then(res => res.ok ? res.json() : 0)
|
||||||
.then(data => setAverageRating(data))
|
.then(data => setAverageRating(data || 0))
|
||||||
.catch(err => console.error("Failed to fetch avg rating", err))
|
.catch(() => setAverageRating(0))
|
||||||
|
|
||||||
// Fetch user rating (if logged in)
|
|
||||||
// TODO: Implement fetching user's existing rating
|
|
||||||
}, [entityType, entityId])
|
}, [entityType, entityId])
|
||||||
|
|
||||||
const handleRate = async (score: number) => {
|
const handleRate = async (score: number) => {
|
||||||
|
|
@ -35,7 +33,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const body: any = { score }
|
const body: Record<string, unknown> = { score }
|
||||||
body[`${entityType}_id`] = entityId
|
body[`${entityType}_id`] = entityId
|
||||||
|
|
||||||
const res = await fetch(`${getApiUrl()}/social/ratings`, {
|
const res = await fetch(`${getApiUrl()}/social/ratings`, {
|
||||||
|
|
@ -51,10 +49,11 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setUserRating(data.score)
|
setUserRating(data.score)
|
||||||
|
setHasRated(true)
|
||||||
|
|
||||||
// Re-fetch average to keep it lively
|
// Re-fetch average
|
||||||
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
|
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
|
||||||
.then(res => res.json())
|
.then(res => res.ok ? res.json() : averageRating)
|
||||||
.then(setAverageRating)
|
.then(setAverageRating)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -66,24 +65,41 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 opacity-80 hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-2">
|
||||||
<StarRating value={userRating} onChange={handleRate} size="sm" />
|
|
||||||
{averageRating > 0 && (
|
{averageRating > 0 && (
|
||||||
<span className="text-[10px] text-muted-foreground font-mono">
|
<RatingBadge value={averageRating} />
|
||||||
{averageRating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-l pl-4">
|
<div className="border rounded-lg p-4 bg-card">
|
||||||
<div className="flex flex-col">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-medium">Rating:</span>
|
<span className="text-sm font-medium">Your Rating</span>
|
||||||
<span className="text-xs text-muted-foreground">Avg: {averageRating.toFixed(1)}</span>
|
{averageRating > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Community avg: <span className="font-medium">{averageRating.toFixed(1)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</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