feat: Support decimal ratings (e.g., 9.2)
- Rating/Review models now use float instead of int - Star rating component shows partial fills - Numeric value displayed while rating - Supports precision: 0.1 increments
This commit is contained in:
parent
ee311c0bc4
commit
835299fab5
3 changed files with 110 additions and 32 deletions
|
|
@ -148,7 +148,7 @@ class Comment(SQLModel, table=True):
|
||||||
class Rating(SQLModel, table=True):
|
class Rating(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
score: int = Field(ge=1, le=10, description="Rating from 1 to 10")
|
score: float = Field(ge=1.0, le=10.0, description="Rating from 1.0 to 10.0")
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
|
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
|
||||||
|
|
@ -222,7 +222,7 @@ class Review(SQLModel, table=True):
|
||||||
user_id: int = Field(foreign_key="user.id")
|
user_id: int = Field(foreign_key="user.id")
|
||||||
blurb: str = Field(description="One-liner/pullquote")
|
blurb: str = Field(description="One-liner/pullquote")
|
||||||
content: str = Field(description="Full review text")
|
content: str = Field(description="Full review text")
|
||||||
score: int = Field(ge=1, le=10)
|
score: float = Field(ge=1.0, le=10.0)
|
||||||
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
|
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
|
||||||
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
|
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
|
||||||
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
|
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ class CommentRead(CommentBase):
|
||||||
# We might want to include the username here later
|
# We might want to include the username here later
|
||||||
|
|
||||||
class RatingBase(SQLModel):
|
class RatingBase(SQLModel):
|
||||||
score: int
|
score: float
|
||||||
show_id: Optional[int] = None
|
show_id: Optional[int] = None
|
||||||
song_id: Optional[int] = None
|
song_id: Optional[int] = None
|
||||||
performance_id: Optional[int] = None
|
performance_id: Optional[int] = None
|
||||||
|
|
@ -237,7 +237,7 @@ class RatingRead(RatingBase):
|
||||||
class ReviewBase(SQLModel):
|
class ReviewBase(SQLModel):
|
||||||
blurb: str
|
blurb: str
|
||||||
content: str
|
content: str
|
||||||
score: int
|
score: float
|
||||||
show_id: Optional[int] = None
|
show_id: Optional[int] = None
|
||||||
venue_id: Optional[int] = None
|
venue_id: Optional[int] = None
|
||||||
song_id: Optional[int] = None
|
song_id: Optional[int] = None
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,125 @@
|
||||||
import { useState } from "react"
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef } from "react"
|
||||||
import { Star } from "lucide-react"
|
import { Star } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
||||||
interface RatingProps {
|
interface RatingProps {
|
||||||
value: number
|
value: number
|
||||||
onChange?: (value: number) => void
|
onChange?: (value: number) => void
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
size?: "sm" | "md"
|
size?: "sm" | "md" | "lg"
|
||||||
|
precision?: "full" | "half" | "decimal"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StarRating({ value, onChange, readonly = false, className, size = "md" }: RatingProps) {
|
export function StarRating({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readonly = false,
|
||||||
|
className,
|
||||||
|
size = "md",
|
||||||
|
precision = "decimal"
|
||||||
|
}: RatingProps) {
|
||||||
const [hoverValue, setHoverValue] = useState<number | null>(null)
|
const [hoverValue, setHoverValue] = useState<number | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const stars = Array.from({ length: 10 }, (_, i) => i + 1)
|
const stars = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
const starSize = size === "sm" ? "h-3 w-3" : "h-4 w-4"
|
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 (
|
return (
|
||||||
<div className={cn("flex gap-0.5", className)}>
|
<div
|
||||||
{stars.map((star) => (
|
ref={containerRef}
|
||||||
<button
|
className={cn("flex gap-0.5 items-center", className)}
|
||||||
key={star}
|
onMouseLeave={() => !readonly && setHoverValue(null)}
|
||||||
type="button"
|
>
|
||||||
disabled={readonly}
|
{stars.map((star, index) => {
|
||||||
className={cn(
|
const fillPercent = getStarFill(index)
|
||||||
"p-0.5 transition-colors",
|
|
||||||
readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
|
return (
|
||||||
)}
|
<button
|
||||||
onMouseEnter={() => !readonly && setHoverValue(star)}
|
key={star}
|
||||||
onMouseLeave={() => !readonly && setHoverValue(null)}
|
type="button"
|
||||||
onClick={() => !readonly && onChange?.(star)}
|
disabled={readonly}
|
||||||
>
|
|
||||||
<Star
|
|
||||||
className={cn(
|
className={cn(
|
||||||
starSize,
|
"p-0.5 transition-transform relative",
|
||||||
(hoverValue !== null ? star <= hoverValue : star <= value)
|
readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
|
||||||
? "fill-primary text-primary"
|
|
||||||
: "fill-muted text-muted-foreground"
|
|
||||||
)}
|
)}
|
||||||
/>
|
onMouseMove={(e) => handleMouseMove(e, index)}
|
||||||
</button>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue