Initial commit: DrinkTracker full-stack app
Next.js 14 drink collection tracker with AI-powered search, menu scanning, ratings, wishlist, sharing, and CSV backup/restore. Features: - Auth (credentials + OAuth ready) - Drink collection with ratings and reviews - AI search via Claude/OpenAI with search history - Menu photo scanning with AI extraction - Wishlist / Try Later system - Public sharing via slug URLs - CSV backup and restore (merge/replace modes) - Docker Compose for Postgres + MinIO + dev server Security: docker-compose files use env var interpolation instead of hardcoded secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
122
src/components/ratings/star-rating.tsx
Normal file
122
src/components/ratings/star-rating.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { Star } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number
|
||||
onChange?: (value: number) => void
|
||||
size?: "sm" | "md" | "lg"
|
||||
readOnly?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-6 w-6",
|
||||
lg: "h-8 w-8",
|
||||
}
|
||||
|
||||
const gapClasses = {
|
||||
sm: "gap-0.5",
|
||||
md: "gap-1",
|
||||
lg: "gap-1.5",
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
size = "md",
|
||||
readOnly = false,
|
||||
className,
|
||||
}: StarRatingProps) {
|
||||
const [hoverValue, setHoverValue] = useState(0)
|
||||
const [, setIsFocused] = useState(false)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(starValue: number) => {
|
||||
if (!readOnly && onChange) {
|
||||
onChange(starValue)
|
||||
}
|
||||
},
|
||||
[readOnly, onChange]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (readOnly || !onChange) return
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
onChange(Math.min(5, value + 1))
|
||||
break
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
onChange(Math.max(1, value - 1))
|
||||
break
|
||||
case "Home":
|
||||
e.preventDefault()
|
||||
onChange(1)
|
||||
break
|
||||
case "End":
|
||||
e.preventDefault()
|
||||
onChange(5)
|
||||
break
|
||||
}
|
||||
},
|
||||
[readOnly, onChange, value]
|
||||
)
|
||||
|
||||
const displayValue = hoverValue || value
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("inline-flex items-center", gapClasses[size], className)}
|
||||
role="radiogroup"
|
||||
aria-label="Star rating"
|
||||
onMouseLeave={() => !readOnly && setHoverValue(0)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const starValue = i + 1
|
||||
const isFilled = starValue <= displayValue
|
||||
const isInteractive = !readOnly && !!onChange
|
||||
|
||||
return (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={starValue === value}
|
||||
aria-label={`${starValue} star${starValue !== 1 ? "s" : ""}`}
|
||||
tabIndex={isInteractive ? (starValue === value || (value === 0 && starValue === 1) ? 0 : -1) : -1}
|
||||
disabled={readOnly}
|
||||
className={cn(
|
||||
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-sm",
|
||||
isInteractive && "cursor-pointer hover:scale-110 transition-transform",
|
||||
readOnly && "cursor-default"
|
||||
)}
|
||||
onClick={() => handleClick(starValue)}
|
||||
onMouseEnter={() => isInteractive && setHoverValue(starValue)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
sizeClasses[size],
|
||||
"transition-colors",
|
||||
isFilled
|
||||
? "fill-primary text-primary"
|
||||
: "fill-none text-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user