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>
123 lines
3.1 KiB
TypeScript
123 lines
3.1 KiB
TypeScript
"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>
|
|
)
|
|
}
|