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:
JP Scott
2026-03-01 12:27:08 -07:00
commit 969bc9347a
115 changed files with 19397 additions and 0 deletions

View 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>
)
}