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,70 @@
"use client"
import { useState } from "react"
import { Bookmark, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAddToWishlist } from "@/hooks/use-wishlist"
interface AddToWishlistButtonProps {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string | null
brewery?: string | null
abv?: number | null
description?: string | null
source?: string
size?: "sm" | "default"
}
export function AddToWishlistButton({
name,
type,
subType,
brewery,
abv,
description,
source = "scan",
size = "sm",
}: AddToWishlistButtonProps) {
const addToWishlist = useAddToWishlist()
const [added, setAdded] = useState(false)
function handleAdd() {
addToWishlist.mutate(
{
name,
type,
subType: subType || undefined,
brewery: brewery || undefined,
abv: abv || undefined,
description: description || undefined,
source,
},
{
onSuccess: () => setAdded(true),
}
)
}
return (
<Button
size={size}
variant={added ? "secondary" : "ghost"}
onClick={handleAdd}
disabled={added || addToWishlist.isPending}
title="Save to Try Later"
>
{added ? (
<>
<Check className="h-3 w-3 mr-1" />
Saved
</>
) : (
<>
<Bookmark className="h-3 w-3 mr-1" />
Later
</>
)}
</Button>
)
}

View File

@@ -0,0 +1,256 @@
"use client"
import { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { Search, Loader2, Plus, Sparkles, Check, Clock, ArrowLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { AddToWishlistButton } from "@/components/drinks/add-to-wishlist-button"
import { useSearchHistory, type SearchHistoryItem } from "@/hooks/use-search-history"
import { cn } from "@/lib/utils"
interface SearchResult {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
abv?: number
description?: string
}
type ItemState = "loading" | "added" | "error"
interface AiDrinkSearchProps {
onAdd: (drink: SearchResult) => Promise<void>
}
const typeColors: Record<string, string> = {
BEER: "bg-amber-100 text-amber-800",
WINE: "bg-purple-100 text-purple-800",
COCKTAIL: "bg-blue-100 text-blue-800",
SPIRIT: "bg-orange-100 text-orange-800",
OTHER: "bg-gray-100 text-gray-800",
}
export function AiDrinkSearch({ onAdd }: AiDrinkSearchProps) {
const [query, setQuery] = useState("")
const [itemStates, setItemStates] = useState<Map<number, ItemState>>(new Map())
const [cachedResults, setCachedResults] = useState<{ query: string; drinks: SearchResult[] } | null>(null)
const history = useSearchHistory()
const search = useMutation({
mutationFn: async (q: string) => {
const res = await fetch("/api/ai/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.error || "Search failed")
}
return res.json() as Promise<{ drinks: SearchResult[] }>
},
})
function handleSearch(e: React.FormEvent) {
e.preventDefault()
if (!query.trim()) return
setCachedResults(null)
setItemStates(new Map())
search.mutate(query.trim())
}
function handleViewCached(item: SearchHistoryItem) {
setCachedResults({
query: item.query,
drinks: item.results.drinks as SearchResult[],
})
setItemStates(new Map())
search.reset()
}
function handleBackToHistory() {
setCachedResults(null)
setItemStates(new Map())
search.reset()
}
async function handleAdd(drink: SearchResult, index: number) {
const state = itemStates.get(index)
if (state === "loading" || state === "added") return
setItemStates((prev) => new Map(prev).set(index, "loading"))
try {
await onAdd(drink)
setItemStates((prev) => new Map(prev).set(index, "added"))
} catch {
setItemStates((prev) => new Map(prev).set(index, "error"))
}
}
// Which drinks to display — either from a new AI search or from cached history
const activeDrinks = cachedResults?.drinks ?? search.data?.drinks ?? null
const activeQuery = cachedResults?.query ?? (search.data ? query : null)
const showHistory = !activeDrinks && !search.isPending
return (
<div className="space-y-4">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name, style, or description... e.g. 'hazy IPA' or 'Two Hearted Ale'"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" disabled={search.isPending || !query.trim()}>
{search.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</form>
{/* Recent searches - shown when no results are displayed */}
{showHistory && history.data && history.data.searches.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
<span>Recent Searches</span>
</div>
<div className="space-y-1">
{history.data.searches.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleViewCached(item)}
className="w-full flex items-center justify-between gap-3 px-3 py-2.5 text-sm rounded-lg border bg-background hover:bg-accent transition-colors text-left"
>
<div className="flex items-center gap-2.5 min-w-0">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate font-medium">{item.query}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground">
{item.results.drinks.length} results
</span>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</button>
))}
</div>
</div>
)}
{search.isError && (
<p className="text-sm text-destructive">{search.error.message}</p>
)}
{/* Results header with back button */}
{activeDrinks && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleBackToHistory}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<span className="text-sm text-muted-foreground">·</span>
<span className="text-sm font-medium truncate">
&ldquo;{activeQuery}&rdquo;
</span>
<Badge variant="secondary" className="text-xs shrink-0">
{activeDrinks.length} results
</Badge>
</div>
)}
{activeDrinks && activeDrinks.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No results found. Try a different search term.
</p>
)}
{activeDrinks && activeDrinks.length > 0 && (
<div className="space-y-2">
{activeDrinks.map((drink, i) => {
const state = itemStates.get(i)
return (
<Card key={`${drink.name}-${i}`} className="transition-colors hover:bg-accent/30">
<CardContent className="flex items-start justify-between gap-3 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-semibold">{drink.name}</h4>
<Badge
variant="secondary"
className={cn("text-xs", typeColors[drink.type])}
>
{drink.type}
</Badge>
{drink.subType && (
<Badge variant="outline" className="text-xs">
{drink.subType}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
{drink.brewery && <span>{drink.brewery}</span>}
{drink.abv != null && (
<span>{drink.brewery ? "·" : ""} {drink.abv}% ABV</span>
)}
</div>
{drink.description && (
<p className="text-sm text-muted-foreground mt-1">
{drink.description}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Button
size="sm"
variant={state === "added" ? "secondary" : state === "error" ? "destructive" : "outline"}
onClick={() => handleAdd(drink, i)}
disabled={state === "loading" || state === "added"}
>
{state === "loading" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : state === "added" ? (
<>
<Check className="h-3 w-3 mr-1" />
Added
</>
) : state === "error" ? (
"Retry"
) : (
<>
<Plus className="h-3 w-3 mr-1" />
Add
</>
)}
</Button>
<AddToWishlistButton
name={drink.name}
type={drink.type}
subType={drink.subType}
brewery={drink.brewery}
abv={drink.abv}
description={drink.description}
source="ai_search"
/>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import Link from "next/link"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
import type { DrinkListItem } from "@/hooks/use-drinks"
const TYPE_COLORS: Record<string, string> = {
BEER: "bg-amber-500/15 text-amber-700 border-amber-500/25",
WINE: "bg-rose-500/15 text-rose-700 border-rose-500/25",
COCKTAIL: "bg-purple-500/15 text-purple-700 border-purple-500/25",
SPIRIT: "bg-sky-500/15 text-sky-700 border-sky-500/25",
OTHER: "bg-slate-500/15 text-slate-700 border-slate-500/25",
}
const TYPE_LABELS: Record<string, string> = {
BEER: "Beer",
WINE: "Wine",
COCKTAIL: "Cocktail",
SPIRIT: "Spirit",
OTHER: "Other",
}
interface DrinkCardProps {
drink: DrinkListItem
}
export function DrinkCard({ drink }: DrinkCardProps) {
return (
<Link href={`/drinks/${drink.id}`}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold leading-tight line-clamp-2">
{drink.name}
</h3>
<Badge
className={cn(
"shrink-0 text-[11px]",
TYPE_COLORS[drink.type] || TYPE_COLORS.OTHER
)}
>
{TYPE_LABELS[drink.type] || drink.type}
</Badge>
</div>
{drink.subType && (
<p className="text-sm text-muted-foreground">{drink.subType}</p>
)}
{drink.brewery && (
<p className="text-sm text-muted-foreground truncate">
{drink.brewery}
</p>
)}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={cn(
"h-3.5 w-3.5",
drink.avgRating && i < Math.round(drink.avgRating)
? "fill-primary text-primary"
: "fill-none text-muted-foreground/30"
)}
/>
))}
{drink.ratingCount > 0 && (
<span className="text-xs text-muted-foreground ml-1.5">
({drink.avgRating?.toFixed(1)})
</span>
)}
</div>
{drink.abv !== null && drink.abv !== undefined && (
<span className="text-xs text-muted-foreground">
{drink.abv}% ABV
</span>
)}
</div>
</CardContent>
</Card>
</Link>
)
}

View File

@@ -0,0 +1,149 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { DrinkForm } from "@/components/drinks/drink-form"
import { useUpdateDrink, useDeleteDrink, useDrink } from "@/hooks/use-drinks"
import { Pencil, Trash2 } from "lucide-react"
import type { DrinkCreate } from "@/lib/validators"
interface DrinkDetailActionsProps {
drinkId: string
drinkName: string
}
export function DrinkDetailActions({
drinkId,
drinkName,
}: DrinkDetailActionsProps) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const { data: drink } = useDrink(editOpen ? drinkId : undefined)
const updateDrink = useUpdateDrink(drinkId)
const deleteDrink = useDeleteDrink(drinkId)
function handleUpdate(formData: DrinkCreate) {
updateDrink.mutate(formData, {
onSuccess: () => {
setEditOpen(false)
router.refresh()
},
})
}
function handleDelete() {
deleteDrink.mutate(undefined, {
onSuccess: () => {
setDeleteOpen(false)
router.push("/drinks")
},
})
}
return (
<>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5 mr-1.5" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Delete
</Button>
</div>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Drink</DialogTitle>
<DialogDescription>
Update the details for {drinkName}.
</DialogDescription>
</DialogHeader>
{drink ? (
<DrinkForm
initialData={{
name: drink.name,
type: drink.type,
subType: drink.subType || undefined,
brewery: drink.brewery || undefined,
region: drink.region || undefined,
abv: drink.abv || undefined,
description: drink.description || undefined,
}}
onSubmit={handleUpdate}
isSubmitting={updateDrink.isPending}
submitLabel="Update Drink"
/>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
Loading...
</div>
)}
{updateDrink.isError && (
<p className="text-sm text-destructive">
{updateDrink.error.message || "Failed to update drink"}
</p>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Drink</DialogTitle>
<DialogDescription>
Are you sure you want to delete &ldquo;{drinkName}&rdquo;? This
will also remove all associated ratings. This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteDrink.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteDrink.isPending}
>
{deleteDrink.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
{deleteDrink.isError && (
<p className="text-sm text-destructive">
{deleteDrink.error.message || "Failed to delete drink"}
</p>
)}
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,74 @@
"use client"
import { Input } from "@/components/ui/input"
import { Select, SelectOption } from "@/components/ui/select"
import { Search } from "lucide-react"
const DRINK_TYPES = [
{ value: "ALL", label: "All Types" },
{ value: "BEER", label: "Beer" },
{ value: "WINE", label: "Wine" },
{ value: "COCKTAIL", label: "Cocktail" },
{ value: "SPIRIT", label: "Spirit" },
{ value: "OTHER", label: "Other" },
]
const SORT_OPTIONS = [
{ value: "recent", label: "Most Recent" },
{ value: "name", label: "Name (A-Z)" },
{ value: "rating", label: "Highest Rated" },
]
interface DrinkFiltersProps {
search: string
type: string
sort: string
onSearchChange: (value: string) => void
onTypeChange: (value: string) => void
onSortChange: (value: string) => void
}
export function DrinkFilters({
search,
type,
sort,
onSearchChange,
onTypeChange,
onSortChange,
}: DrinkFiltersProps) {
return (
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search drinks..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<Select
value={type}
onChange={(e) => onTypeChange(e.target.value)}
className="sm:w-[160px]"
>
{DRINK_TYPES.map((t) => (
<SelectOption key={t.value} value={t.value}>
{t.label}
</SelectOption>
))}
</Select>
<Select
value={sort}
onChange={(e) => onSortChange(e.target.value)}
className="sm:w-[170px]"
>
{SORT_OPTIONS.map((s) => (
<SelectOption key={s.value} value={s.value}>
{s.label}
</SelectOption>
))}
</Select>
</div>
)
}

View File

@@ -0,0 +1,175 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectOption } from "@/components/ui/select"
import type { DrinkCreate } from "@/lib/validators"
const DRINK_TYPES = [
{ value: "BEER", label: "Beer" },
{ value: "WINE", label: "Wine" },
{ value: "COCKTAIL", label: "Cocktail" },
{ value: "SPIRIT", label: "Spirit" },
{ value: "OTHER", label: "Other" },
]
interface DrinkFormProps {
initialData?: Partial<DrinkCreate>
onSubmit: (data: DrinkCreate) => void
isSubmitting?: boolean
submitLabel?: string
}
export function DrinkForm({
initialData,
onSubmit,
isSubmitting = false,
submitLabel = "Save Drink",
}: DrinkFormProps) {
const [name, setName] = useState(initialData?.name || "")
const [type, setType] = useState(initialData?.type || "BEER")
const [subType, setSubType] = useState(initialData?.subType || "")
const [brewery, setBrewery] = useState(initialData?.brewery || "")
const [region, setRegion] = useState(initialData?.region || "")
const [abv, setAbv] = useState(initialData?.abv?.toString() || "")
const [description, setDescription] = useState(
initialData?.description || ""
)
const [errors, setErrors] = useState<Record<string, string>>({})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const newErrors: Record<string, string> = {}
if (!name.trim()) {
newErrors.name = "Name is required"
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
setErrors({})
const data: DrinkCreate = {
name: name.trim(),
type: type as DrinkCreate["type"],
}
if (subType.trim()) data.subType = subType.trim()
if (brewery.trim()) data.brewery = brewery.trim()
if (region.trim()) data.region = region.trim()
if (abv.trim()) {
const abvNum = parseFloat(abv)
if (!isNaN(abvNum) && abvNum >= 0 && abvNum <= 100) {
data.abv = abvNum
}
}
if (description.trim()) data.description = description.trim()
onSubmit(data)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="drink-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="drink-name"
placeholder="e.g., Two Hearted Ale"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="drink-type">
Type <span className="text-destructive">*</span>
</Label>
<Select
id="drink-type"
value={type}
onChange={(e) => setType(e.target.value as typeof type)}
>
{DRINK_TYPES.map((t) => (
<SelectOption key={t.value} value={t.value}>
{t.label}
</SelectOption>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="drink-subtype">Style / SubType</Label>
<Input
id="drink-subtype"
placeholder="e.g., IPA, Pinot Noir"
value={subType}
onChange={(e) => setSubType(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="drink-brewery">Brewery / Winery</Label>
<Input
id="drink-brewery"
placeholder="e.g., Bell's Brewery"
value={brewery}
onChange={(e) => setBrewery(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="drink-region">Region</Label>
<Input
id="drink-region"
placeholder="e.g., Michigan, Napa Valley"
value={region}
onChange={(e) => setRegion(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="drink-abv">ABV (%)</Label>
<Input
id="drink-abv"
type="number"
step="0.1"
min="0"
max="100"
placeholder="e.g., 7.0"
value={abv}
onChange={(e) => setAbv(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="drink-description">Description</Label>
<Textarea
id="drink-description"
placeholder="Tasting notes, appearance, aroma..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</form>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { LayoutDashboard, Camera, Wine, Bookmark, Settings } from "lucide-react"
import { cn } from "@/lib/utils"
const navItems = [
{ href: "/dashboard", label: "Home", icon: LayoutDashboard },
{ href: "/scan", label: "Scan", icon: Camera },
{ href: "/drinks", label: "Drinks", icon: Wine },
{ href: "/wishlist", label: "Later", icon: Bookmark },
{ href: "/settings", label: "Settings", icon: Settings },
]
export function BottomNav() {
const pathname = usePathname()
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card border-t safe-area-bottom">
<div className="flex items-center justify-around h-16">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center gap-1 px-3 py-2 text-xs font-medium transition-colors min-w-[64px]",
isActive
? "text-primary"
: "text-muted-foreground"
)}
>
<item.icon className="h-5 w-5" />
{item.label}
</Link>
)
})}
</div>
</nav>
)
}

View File

@@ -0,0 +1,14 @@
"use client"
import { Beer } from "lucide-react"
export function Header({ title }: { title?: string }) {
return (
<header className="md:hidden sticky top-0 z-40 flex items-center gap-3 h-14 px-4 border-b bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/60">
<Beer className="h-6 w-6 text-primary" />
<h1 className="font-semibold text-lg">
{title || "DrinkTracker"}
</h1>
</header>
)
}

View File

@@ -0,0 +1,91 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { signOut, useSession } from "next-auth/react"
import {
LayoutDashboard,
Camera,
Wine,
Bookmark,
Settings,
LogOut,
Beer,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/scan", label: "Scan Menu", icon: Camera },
{ href: "/drinks", label: "My Drinks", icon: Wine },
{ href: "/wishlist", label: "Try Later", icon: Bookmark },
{ href: "/settings", label: "Settings", icon: Settings },
]
export function Sidebar() {
const pathname = usePathname()
const { data: session } = useSession()
return (
<aside className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0 border-r bg-card">
<div className="flex flex-col flex-grow pt-5 overflow-y-auto">
<div className="flex items-center gap-2 px-4 mb-8">
<Beer className="h-8 w-8 text-primary" />
<span className="text-xl font-bold">DrinkTracker</span>
</div>
<nav className="flex-1 px-2 space-y-1">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<item.icon className="h-5 w-5" />
{item.label}
</Link>
)
})}
</nav>
<div className="p-4 border-t">
{session?.user && (
<div className="flex items-center gap-3 mb-3">
{session.user.image && (
<img
src={session.user.image}
alt=""
className="h-8 w-8 rounded-full"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{session.user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{session.user.email}
</p>
</div>
</div>
)}
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="h-4 w-4" />
Sign out
</Button>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { SessionProvider } from "next-auth/react"
import { useState } from "react"
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
)
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</SessionProvider>
)
}

View File

@@ -0,0 +1,69 @@
"use client"
import { StarRating } from "@/components/ratings/star-rating"
import { Badge } from "@/components/ui/badge"
import { MapPin, RotateCcw, Calendar } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingDisplayProps {
score: number
notes?: string | null
wouldReorder: boolean
location?: string | null
createdAt: string
className?: string
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
export function RatingDisplay({
score,
notes,
wouldReorder,
location,
createdAt,
className,
}: RatingDisplayProps) {
return (
<div className={cn("space-y-3", className)}>
{/* Stars and badges */}
<div className="flex items-center justify-between">
<StarRating value={score} readOnly size="sm" />
<div className="flex items-center gap-2">
{wouldReorder && (
<Badge variant="default" className="gap-1">
<RotateCcw className="h-3 w-3" />
Would reorder
</Badge>
)}
</div>
</div>
{/* Notes */}
{notes && (
<p className="text-sm text-foreground leading-relaxed">{notes}</p>
)}
{/* Metadata row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(createdAt)}
</span>
{location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{location}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
"use client"
import { useState } from "react"
import { StarRating } from "@/components/ratings/star-rating"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Input } from "@/components/ui/input"
import { MapPin, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingFormData {
score: number
notes?: string
wouldReorder: boolean
location?: string
}
interface RatingFormProps {
initialData?: Partial<RatingFormData>
onSubmit: (data: RatingFormData) => void | Promise<void>
isLoading?: boolean
submitLabel?: string
}
export function RatingForm({
initialData,
onSubmit,
isLoading = false,
submitLabel = "Submit Rating",
}: RatingFormProps) {
const [score, setScore] = useState(initialData?.score || 0)
const [notes, setNotes] = useState(initialData?.notes || "")
const [wouldReorder, setWouldReorder] = useState(
initialData?.wouldReorder ?? false
)
const [location, setLocation] = useState(initialData?.location || "")
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (score < 1 || score > 5) {
setError("Please select a rating between 1 and 5 stars.")
return
}
try {
await onSubmit({
score,
notes: notes.trim() || undefined,
wouldReorder,
location: location.trim() || undefined,
})
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.")
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Star Rating */}
<div className="space-y-2">
<Label>Rating</Label>
<div className="flex items-center gap-3">
<StarRating value={score} onChange={setScore} size="lg" />
{score > 0 && (
<span className="text-sm text-muted-foreground">
{score}/5
</span>
)}
</div>
{error && score === 0 && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
{/* Tasting Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Tasting Notes</Label>
<Textarea
id="notes"
placeholder="What did you think? Describe the flavors, aroma, mouthfeel..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
maxLength={2000}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground text-right">
{notes.length}/2000
</p>
</div>
{/* Would Reorder Toggle */}
<div className="space-y-2">
<Label>Would you order this again?</Label>
<button
type="button"
role="switch"
aria-checked={wouldReorder}
onClick={() => setWouldReorder(!wouldReorder)}
disabled={isLoading}
className={cn(
"flex items-center gap-3 w-full rounded-lg border p-4 text-left transition-colors",
wouldReorder
? "border-primary bg-primary/5"
: "border-input hover:bg-accent/50"
)}
>
<RotateCcw
className={cn(
"h-5 w-5 shrink-0",
wouldReorder ? "text-primary" : "text-muted-foreground"
)}
/>
<div>
<p className="font-medium text-sm">
{wouldReorder ? "Yes, I would reorder!" : "No, probably not"}
</p>
<p className="text-xs text-muted-foreground">
{wouldReorder
? "This drink is a keeper"
: "Tap to mark as a reorder"}
</p>
</div>
</button>
</div>
{/* Location */}
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="location"
placeholder="Where did you try it? (e.g. Bar name, city)"
value={location}
onChange={(e) => setLocation(e.target.value)}
maxLength={200}
disabled={isLoading}
className="pl-9"
/>
</div>
</div>
{/* Error */}
{error && score > 0 && (
<p className="text-sm text-destructive">{error}</p>
)}
{/* Submit */}
<Button
type="submit"
className="w-full"
size="lg"
disabled={isLoading || score === 0}
>
{isLoading ? "Saving..." : submitLabel}
</Button>
</form>
)
}

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

View File

@@ -0,0 +1,131 @@
"use client"
import { useRef, useState, useEffect, useCallback } from "react"
import { Camera, SwitchCamera, X } from "lucide-react"
import { Button } from "@/components/ui/button"
interface CameraCaptureProps {
onCapture: (file: File) => void
onClose: () => void
}
export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const [facingMode, setFacingMode] = useState<"environment" | "user">("environment")
const [error, setError] = useState<string | null>(null)
const startCamera = useCallback(async (facing: "environment" | "user") => {
// Stop existing stream
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop())
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facing },
audio: false,
})
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
}
setError(null)
} catch {
setError("Camera access denied. Please allow camera permissions and try again.")
}
}, [])
useEffect(() => {
startCamera(facingMode)
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop())
}
}
}, [facingMode, startCamera])
function handleCapture() {
const video = videoRef.current
const canvas = canvasRef.current
if (!video || !canvas) return
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.drawImage(video, 0, 0)
canvas.toBlob(
(blob) => {
if (blob) {
const file = new File([blob], `capture-${Date.now()}.jpg`, { type: "image/jpeg" })
// Stop the camera before calling back
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop())
}
onCapture(file)
}
},
"image/jpeg",
0.9
)
}
function toggleCamera() {
setFacingMode((prev) => (prev === "environment" ? "user" : "environment"))
}
if (error) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<Camera className="h-12 w-12 text-muted-foreground" />
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
)
}
return (
<div className="relative rounded-lg overflow-hidden bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full max-h-[70vh] object-contain"
/>
<canvas ref={canvasRef} className="hidden" />
{/* Overlay controls */}
<div className="absolute bottom-0 inset-x-0 flex items-center justify-center gap-4 p-4 bg-gradient-to-t from-black/60 to-transparent">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onClose}
>
<X className="h-5 w-5" />
</Button>
<Button
size="lg"
className="rounded-full h-16 w-16 bg-white hover:bg-white/90"
onClick={handleCapture}
>
<Camera className="h-6 w-6 text-black" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={toggleCamera}
>
<SwitchCamera className="h-5 w-5" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
"use client"
import { Star, Plus, ThumbsUp, Sparkles, Loader2, Check } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { AddToWishlistButton } from "@/components/drinks/add-to-wishlist-button"
import { cn } from "@/lib/utils"
interface MenuItemCardProps {
name: string
type: string
subType?: string | null
brewery?: string | null
abv?: number | null
price?: string | null
description?: string | null
matchedDrinkId?: string | null
userRating?: number | null
aiRecommended?: boolean
aiReason?: string | null
onAddToDrinks?: () => void
onQuickRate?: () => void
isAddingToDrinks?: boolean
wasAddedToDrinks?: boolean
}
const typeColors: Record<string, string> = {
BEER: "bg-amber-100 text-amber-800",
WINE: "bg-purple-100 text-purple-800",
COCKTAIL: "bg-blue-100 text-blue-800",
SPIRIT: "bg-orange-100 text-orange-800",
OTHER: "bg-gray-100 text-gray-800",
}
export function MenuItemCard({
name,
type,
subType,
brewery,
abv,
price,
description,
matchedDrinkId,
userRating,
aiRecommended,
aiReason,
onAddToDrinks,
onQuickRate,
isAddingToDrinks,
wasAddedToDrinks,
}: MenuItemCardProps) {
const isMatched = !!matchedDrinkId
return (
<Card
className={cn(
"transition-colors",
isMatched && "border-green-200 bg-green-50/50",
aiRecommended && !isMatched && "border-primary/30 bg-primary/5"
)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold">{name}</h3>
<Badge
variant="secondary"
className={cn("text-xs", typeColors[type])}
>
{type}
</Badge>
{isMatched && (
<Badge variant="outline" className="text-xs text-green-700 border-green-300">
Tried
</Badge>
)}
{aiRecommended && !isMatched && (
<Badge variant="outline" className="text-xs text-primary border-primary/30">
<Sparkles className="h-3 w-3 mr-1" />
Recommended
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
{brewery && <span>{brewery}</span>}
{subType && <span>{brewery ? "·" : ""} {subType}</span>}
{abv != null && <span>· {abv}%</span>}
{price && <span>· {price}</span>}
</div>
{description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{description}
</p>
)}
{aiReason && (
<p className="text-sm text-primary mt-2 italic">
<Sparkles className="h-3 w-3 inline mr-1" />
{aiReason}
</p>
)}
{isMatched && userRating && (
<div className="flex items-center gap-1 mt-2">
<span className="text-sm text-muted-foreground">Your rating:</span>
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={cn(
"h-4 w-4",
i < userRating
? "fill-primary text-primary"
: "text-muted-foreground/30"
)}
/>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1">
{!isMatched && (
<>
<Button
size="sm"
variant={wasAddedToDrinks ? "secondary" : "outline"}
onClick={onAddToDrinks}
disabled={isAddingToDrinks || wasAddedToDrinks}
>
{isAddingToDrinks ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : wasAddedToDrinks ? (
<>
<Check className="h-3 w-3 mr-1" />
Added
</>
) : (
<>
<Plus className="h-3 w-3 mr-1" />
Add
</>
)}
</Button>
<AddToWishlistButton
name={name}
type={type as "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"}
subType={subType}
brewery={brewery}
abv={abv}
description={description}
source="scan"
/>
</>
)}
{isMatched && (
<Button size="sm" variant="outline" onClick={onQuickRate}>
<ThumbsUp className="h-3 w-3 mr-1" />
Rate
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,172 @@
"use client"
import { useRef, useState, useCallback, useEffect } from "react"
import { Camera, Upload, X, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { CameraCapture } from "@/components/scan/camera-capture"
import { cn } from "@/lib/utils"
interface PhotoUploadProps {
onUpload: (file: File) => void
isUploading?: boolean
accept?: string
}
export function PhotoUpload({
onUpload,
isUploading = false,
accept = "image/jpeg,image/png,image/webp,image/heic",
}: PhotoUploadProps) {
const [preview, setPreview] = useState<string | null>(null)
const [dragActive, setDragActive] = useState(false)
const [showCamera, setShowCamera] = useState(false)
const [hasGetUserMedia, setHasGetUserMedia] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setHasGetUserMedia(
typeof navigator !== "undefined" &&
!!navigator.mediaDevices?.getUserMedia
)
}, [])
const handleFile = useCallback(
(file: File) => {
const reader = new FileReader()
reader.onload = (e) => setPreview(e.target?.result as string)
reader.readAsDataURL(file)
onUpload(file)
},
[onUpload]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setDragActive(false)
const file = e.dataTransfer.files[0]
if (file && file.type.startsWith("image/")) {
handleFile(file)
}
},
[handleFile]
)
const clearPreview = () => {
setPreview(null)
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
}
if (showCamera) {
return (
<CameraCapture
onCapture={(file) => {
setShowCamera(false)
handleFile(file)
}}
onClose={() => setShowCamera(false)}
/>
)
}
if (preview) {
return (
<div className="relative">
<img
src={preview}
alt="Upload preview"
className="w-full rounded-lg max-h-[400px] object-contain bg-muted"
/>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 rounded-lg">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm font-medium">Analyzing menu...</p>
</div>
</div>
)}
{!isUploading && (
<Button
variant="destructive"
size="icon"
className="absolute top-2 right-2"
onClick={clearPreview}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)
}
return (
<Card
className={cn(
"border-2 border-dashed transition-colors",
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25"
)}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={() => setDragActive(false)}
onDrop={handleDrop}
>
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex gap-3">
<Button
size="lg"
onClick={() => {
if (hasGetUserMedia) {
setShowCamera(true)
} else {
cameraInputRef.current?.click()
}
}}
className="gap-2"
>
<Camera className="h-5 w-5" />
Take Photo
</Button>
<Button
size="lg"
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
<Upload className="h-5 w-5" />
Upload
</Button>
</div>
<p className="text-sm text-muted-foreground text-center">
Take a photo of a menu or label, or drag and drop an image here
</p>
<input
ref={cameraInputRef}
type="file"
accept={accept}
capture="environment"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useRef, useState } from "react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Download, Upload, Database, Loader2 } from "lucide-react"
import { useExportBackup } from "@/hooks/use-backup"
import { RestoreDialog } from "@/components/settings/restore-dialog"
export function BackupRestore() {
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const exportBackup = useExportBackup()
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
// Validate file type
if (!file.name.endsWith(".csv") && file.type !== "text/csv") {
alert("Please select a CSV file.")
return
}
setSelectedFile(file)
setDialogOpen(true)
// Reset input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Backup & Restore
</CardTitle>
<CardDescription>
Export your data as a CSV file or restore from a previous backup
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Backup section */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Backup</h4>
<p className="text-xs text-muted-foreground">
Download a CSV backup of all your drinks, ratings, wishlist items,
preferences, and shared lists.
</p>
<Button
variant="outline"
onClick={() => exportBackup.mutate()}
disabled={exportBackup.isPending}
>
{exportBackup.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Download Backup
</>
)}
</Button>
{exportBackup.isError && (
<p className="text-xs text-destructive">
{exportBackup.error.message}
</p>
)}
</div>
<Separator />
{/* Restore section */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Restore</h4>
<p className="text-xs text-muted-foreground">
Upload a previously exported CSV backup file to restore your data.
</p>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Upload CSV File
</Button>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handleFileSelect}
/>
</div>
</CardContent>
</Card>
<RestoreDialog
file={selectedFile}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
}

View File

@@ -0,0 +1,240 @@
"use client"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Select, SelectOption } from "@/components/ui/select"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Loader2, AlertTriangle, CheckCircle } from "lucide-react"
import { useRestoreBackup } from "@/hooks/use-backup"
import type { RestoreSummary } from "@/lib/backup"
interface RestoreDialogProps {
file: File | null
open: boolean
onOpenChange: (open: boolean) => void
}
const MODE_DESCRIPTIONS: Record<string, string> = {
"merge-skip":
"Existing records matching by name and type will be kept unchanged. New records from the backup will be added.",
"merge-update":
"Existing records matching by name and type will be updated with data from the backup. New records will be added.",
replace:
"All your current data will be permanently deleted and replaced with data from this backup file. This cannot be undone.",
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function RestoreDialog({
file,
open,
onOpenChange,
}: RestoreDialogProps) {
const [mode, setMode] = useState<string>("merge-skip")
const [summary, setSummary] = useState<RestoreSummary | null>(null)
const restore = useRestoreBackup()
function handleRestore() {
if (!file) return
restore.mutate(
{ file, mode },
{
onSuccess: (data) => {
setSummary(data.summary)
},
}
)
}
function handleClose() {
if (restore.isPending) return
onOpenChange(false)
// Reset state after close animation
setTimeout(() => {
setSummary(null)
setMode("merge-skip")
restore.reset()
}, 200)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[480px]">
{summary ? (
// ─── Success Summary ─────────────────────────
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
Restore Complete
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<SummaryRow
label="Drinks"
data={summary.drinks}
/>
<SummaryRow
label="Ratings"
data={summary.ratings}
/>
<SummaryRow
label="Wishlist"
data={summary.wishlist}
/>
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground">Preferences</span>
<Badge variant={summary.preferences.restored ? "default" : "secondary"}>
{summary.preferences.restored ? "Restored" : "Unchanged"}
</Badge>
</div>
<SummaryRow
label="Shared Lists"
data={summary.sharedLists}
/>
</div>
<DialogFooter>
<Button onClick={handleClose}>Done</Button>
</DialogFooter>
</>
) : (
// ─── Restore Form ───────────────────────────
<>
<DialogHeader>
<DialogTitle>Restore from Backup</DialogTitle>
<DialogDescription>
Choose how to handle existing data
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* File info */}
{file && (
<div className="rounded-md border p-3 text-sm">
<p className="font-medium truncate">{file.name}</p>
<p className="text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
)}
{/* Mode selector */}
<div className="space-y-2">
<Label>Restore Mode</Label>
<Select
value={mode}
onChange={(e) => setMode(e.target.value)}
disabled={restore.isPending}
>
<SelectOption value="merge-skip">
Merge (skip duplicates)
</SelectOption>
<SelectOption value="merge-update">
Merge (update duplicates)
</SelectOption>
<SelectOption value="replace">Replace all</SelectOption>
</Select>
<p className="text-xs text-muted-foreground">
{MODE_DESCRIPTIONS[mode]}
</p>
</div>
{/* Warning for replace mode */}
{mode === "replace" && (
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
This will permanently delete all your current drinks,
ratings, wishlist items, preferences, and shared lists.
</span>
</div>
)}
{/* Error */}
{restore.isError && (
<p className="text-sm text-destructive">
{restore.error.message}
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={restore.isPending}
>
Cancel
</Button>
<Button
variant={mode === "replace" ? "destructive" : "default"}
onClick={handleRestore}
disabled={restore.isPending || !file}
>
{restore.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Restoring...
</>
) : mode === "replace" ? (
"I understand, Replace All"
) : (
"Restore"
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
function SummaryRow({
label,
data,
}: {
label: string
data: { created: number; updated: number; skipped: number }
}) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
{data.created > 0 && (
<Badge variant="default" className="text-xs">
{data.created} created
</Badge>
)}
{data.updated > 0 && (
<Badge variant="secondary" className="text-xs">
{data.updated} updated
</Badge>
)}
{data.skipped > 0 && (
<Badge variant="outline" className="text-xs">
{data.skipped} skipped
</Badge>
)}
{data.created === 0 && data.updated === 0 && data.skipped === 0 && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
"use client"
import { useState } from "react"
import { Share2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ShareDialog } from "./share-dialog"
export function ShareButton() {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
<ShareDialog open={open} onOpenChange={setOpen} />
</>
)
}

View File

@@ -0,0 +1,189 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Copy, Check, Trash2, Link2, Loader2 } from "lucide-react"
interface SharedList {
id: string
slug: string
title: string
description: string | null
listType: string
isPublic: boolean
createdAt: string
}
interface ShareDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
async function fetchWithError(url: string, options?: RequestInit) {
const res = await fetch(url, options)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `Request failed`)
}
return res.json()
}
export function ShareDialog({ open, onOpenChange }: ShareDialogProps) {
const queryClient = useQueryClient()
const [title, setTitle] = useState("")
const [listType, setListType] = useState("collection")
const [copiedSlug, setCopiedSlug] = useState<string | null>(null)
const { data, isLoading } = useQuery<{ lists: SharedList[] }>({
queryKey: ["shared-lists"],
queryFn: () => fetchWithError("/api/shared-lists"),
enabled: open,
})
const createList = useMutation({
mutationFn: (data: { title: string; listType: string }) =>
fetchWithError("/api/shared-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shared-lists"] })
setTitle("")
},
})
const deleteList = useMutation({
mutationFn: (id: string) =>
fetchWithError(`/api/shared-lists/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shared-lists"] })
},
})
function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!title.trim()) return
createList.mutate({ title: title.trim(), listType })
}
function copyLink(slug: string) {
const url = `${window.location.origin}/share/${slug}`
navigator.clipboard.writeText(url)
setCopiedSlug(slug)
setTimeout(() => setCopiedSlug(null), 2000)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Share Your Drinks</DialogTitle>
<DialogDescription>
Create shareable links to your drink collection or wishlist.
</DialogDescription>
</DialogHeader>
{/* Existing shared lists */}
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : data?.lists && data.lists.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium">Your shared links</p>
{data.lists.map((list) => (
<div
key={list.id}
className="flex items-center justify-between gap-2 p-3 rounded-lg border"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{list.title}</p>
<p className="text-xs text-muted-foreground">
{list.listType} · /share/{list.slug}
</p>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => copyLink(list.slug)}
>
{copiedSlug === list.slug ? (
<>
<Check className="h-3 w-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-3 w-3 mr-1" />
Copy
</>
)}
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => deleteList.mutate(list.id)}
disabled={deleteList.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : null}
{/* Create new shared list */}
<form onSubmit={handleCreate} className="space-y-3 pt-2 border-t">
<p className="text-sm font-medium">Create new shared link</p>
<div>
<Label htmlFor="share-title">Title</Label>
<Input
id="share-title"
placeholder="My Top Beers"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div>
<Label htmlFor="share-type">What to share</Label>
<select
id="share-type"
value={listType}
onChange={(e) => setListType(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="collection">My Full Collection</option>
<option value="wishlist">My Wishlist</option>
</select>
</div>
<Button
type="submit"
className="w-full"
disabled={createList.isPending || !title.trim()}
>
{createList.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Link2 className="h-4 w-4 mr-2" />
)}
Create Share Link
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,35 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,203 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
interface DialogContextValue {
open: boolean
onOpenChange: (open: boolean) => void
}
const DialogContext = React.createContext<DialogContextValue>({
open: false,
onOpenChange: () => {},
})
interface DialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function Dialog({ open = false, onOpenChange, children }: DialogProps) {
const [internalOpen, setInternalOpen] = React.useState(open)
const isControlled = onOpenChange !== undefined
const isOpen = isControlled ? open : internalOpen
const setOpen = isControlled ? onOpenChange : setInternalOpen
return (
<DialogContext.Provider value={{ open: isOpen, onOpenChange: setOpen }}>
{children}
</DialogContext.Provider>
)
}
interface DialogTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
({ className, onClick, ...props }, ref) => {
const { onOpenChange } = React.useContext(DialogContext)
return (
<button
ref={ref}
className={cn(className)}
onClick={(e) => {
onOpenChange(true)
onClick?.(e)
}}
{...props}
/>
)
}
)
DialogTrigger.displayName = "DialogTrigger"
interface DialogPortalProps {
children: React.ReactNode
}
function DialogPortal({ children }: DialogPortalProps) {
return <>{children}</>
}
const DialogOverlay = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { onOpenChange } = React.useContext(DialogContext)
return (
<div
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
onClick={() => onOpenChange(false)}
{...props}
/>
)
})
DialogOverlay.displayName = "DialogOverlay"
const DialogContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { open, onOpenChange } = React.useContext(DialogContext)
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onOpenChange(false)
}
}
if (open) {
document.addEventListener("keydown", handleEscape)
document.body.style.overflow = "hidden"
}
return () => {
document.removeEventListener("keydown", handleEscape)
document.body.style.overflow = ""
}
}, [open, onOpenChange])
if (!open) return null
return (
<DialogPortal>
<DialogOverlay />
<div
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
{...props}
>
{children}
<button
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</DialogPortal>
)
})
DialogContent.displayName = "DialogContent"
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = "DialogTitle"
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = "DialogDescription"
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,211 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
// --- Dropdown Context ---
interface DropdownContextValue {
open: boolean
onOpenChange: (open: boolean) => void
}
const DropdownContext = React.createContext<DropdownContextValue>({
open: false,
onOpenChange: () => {},
})
// --- DropdownMenu ---
interface DropdownMenuProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function DropdownMenu({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
children,
}: DropdownMenuProps) {
const [internalOpen, setInternalOpen] = React.useState(false)
const isControlled = controlledOnOpenChange !== undefined
const open = isControlled ? (controlledOpen ?? false) : internalOpen
const onOpenChange = isControlled ? controlledOnOpenChange : setInternalOpen
return (
<DropdownContext.Provider value={{ open, onOpenChange }}>
<div className="relative inline-block text-left">{children}</div>
</DropdownContext.Provider>
)
}
// --- DropdownMenuTrigger ---
interface DropdownMenuTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
const DropdownMenuTrigger = React.forwardRef<
HTMLButtonElement,
DropdownMenuTriggerProps
>(({ className, onClick, ...props }, ref) => {
const { open, onOpenChange } = React.useContext(DropdownContext)
return (
<button
ref={ref}
className={cn(className)}
aria-expanded={open}
aria-haspopup="true"
onClick={(e) => {
onOpenChange(!open)
onClick?.(e)
}}
{...props}
/>
)
})
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
// --- DropdownMenuContent ---
interface DropdownMenuContentProps
extends React.HTMLAttributes<HTMLDivElement> {
align?: "start" | "center" | "end"
sideOffset?: number
}
const DropdownMenuContent = React.forwardRef<
HTMLDivElement,
DropdownMenuContentProps
>(({ className, align = "center", children, ...props }, ref) => {
const { open, onOpenChange } = React.useContext(DropdownContext)
const contentRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!open) return
const handleClickOutside = (e: MouseEvent) => {
if (
contentRef.current &&
!contentRef.current.contains(e.target as Node) &&
!(e.target as HTMLElement).closest("[aria-haspopup]")
) {
onOpenChange(false)
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onOpenChange(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
}
}, [open, onOpenChange])
if (!open) return null
return (
<div
ref={(node) => {
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current =
node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
}}
className={cn(
"absolute z-50 mt-1 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
align === "start" && "left-0",
align === "center" && "left-1/2 -translate-x-1/2",
align === "end" && "right-0",
className
)}
{...props}
>
{children}
</div>
)
})
DropdownMenuContent.displayName = "DropdownMenuContent"
// --- DropdownMenuItem ---
interface DropdownMenuItemProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
inset?: boolean
}
const DropdownMenuItem = React.forwardRef<
HTMLButtonElement,
DropdownMenuItemProps
>(({ className, inset, onClick, ...props }, ref) => {
const { onOpenChange } = React.useContext(DropdownContext)
return (
<button
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50",
inset && "pl-8",
className
)}
onClick={(e) => {
onClick?.(e)
onOpenChange(false)
}}
{...props}
/>
)
})
DropdownMenuItem.displayName = "DropdownMenuItem"
// --- DropdownMenuSeparator ---
const DropdownMenuSeparator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
// --- DropdownMenuLabel ---
const DropdownMenuLabel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<div
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = "DropdownMenuLabel"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement>,
VariantProps<typeof labelVariants> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)
)
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
export interface SelectProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<div className="relative">
<select
className={cn(
"flex h-10 w-full appearance-none rounded-md border border-input bg-background px-3 py-2 pr-8 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
>
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-3 h-4 w-4 opacity-50" />
</div>
)
}
)
Select.displayName = "Select"
export interface SelectOptionProps
extends React.OptionHTMLAttributes<HTMLOptionElement> {}
const SelectOption = React.forwardRef<HTMLOptionElement, SelectOptionProps>(
({ className, ...props }, ref) => {
return <option ref={ref} className={cn(className)} {...props} />
}
)
SelectOption.displayName = "SelectOption"
export interface SelectGroupProps
extends React.OptgroupHTMLAttributes<HTMLOptGroupElement> {}
const SelectGroup = React.forwardRef<HTMLOptGroupElement, SelectGroupProps>(
({ className, ...props }, ref) => {
return <optgroup ref={ref} className={cn(className)} {...props} />
}
)
SelectGroup.displayName = "SelectGroup"
export { Select, SelectOption, SelectGroup }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical"
decorative?: boolean
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={decorative ? undefined : orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

232
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,232 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
success:
"border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100",
},
},
defaultVariants: {
variant: "default",
},
}
)
// --- Toast Types ---
type ToastVariant = VariantProps<typeof toastVariants>["variant"]
interface ToastMessage {
id: string
title?: string
description?: string
variant?: ToastVariant
duration?: number
}
interface ToastState {
toasts: ToastMessage[]
}
type ToastAction =
| { type: "ADD_TOAST"; toast: ToastMessage }
| { type: "REMOVE_TOAST"; id: string }
// --- Toast Reducer ---
function toastReducer(state: ToastState, action: ToastAction): ToastState {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [...state.toasts, action.toast],
}
case "REMOVE_TOAST":
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.id),
}
default:
return state
}
}
// --- Toast Context ---
interface ToastContextValue {
toasts: ToastMessage[]
toast: (props: Omit<ToastMessage, "id">) => void
dismiss: (id: string) => void
}
const ToastContext = React.createContext<ToastContextValue | undefined>(
undefined
)
let toastCount = 0
function genId() {
toastCount = (toastCount + 1) % Number.MAX_SAFE_INTEGER
return toastCount.toString()
}
// --- Toast Provider ---
interface ToastProviderProps {
children: React.ReactNode
}
function ToastProvider({ children }: ToastProviderProps) {
const [state, dispatch] = React.useReducer(toastReducer, { toasts: [] })
const toast = React.useCallback(
(props: Omit<ToastMessage, "id">) => {
const id = genId()
const duration = props.duration ?? 5000
dispatch({ type: "ADD_TOAST", toast: { ...props, id } })
if (duration > 0) {
setTimeout(() => {
dispatch({ type: "REMOVE_TOAST", id })
}, duration)
}
},
[]
)
const dismiss = React.useCallback((id: string) => {
dispatch({ type: "REMOVE_TOAST", id })
}, [])
return (
<ToastContext.Provider value={{ toasts: state.toasts, toast, dismiss }}>
{children}
<ToastViewport toasts={state.toasts} dismiss={dismiss} />
</ToastContext.Provider>
)
}
// --- useToast Hook ---
function useToast() {
const context = React.useContext(ToastContext)
if (!context) {
throw new Error("useToast must be used within a ToastProvider")
}
return context
}
// --- Toast UI Components ---
interface ToastViewportProps {
toasts: ToastMessage[]
dismiss: (id: string) => void
}
function ToastViewport({ toasts, dismiss }: ToastViewportProps) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:max-w-[420px]">
{toasts.map((t) => (
<Toast key={t.id} variant={t.variant}>
<div className="grid gap-1">
{t.title && <ToastTitle>{t.title}</ToastTitle>}
{t.description && (
<ToastDescription>{t.description}</ToastDescription>
)}
</div>
<ToastClose onClick={() => dismiss(t.id)} />
</Toast>
))}
</div>
)
}
// --- Toast Primitives ---
interface ToastProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof toastVariants> {}
const Toast = React.forwardRef<HTMLDivElement, ToastProps>(
({ className, variant, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
}
)
Toast.displayName = "Toast"
const ToastTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = "ToastTitle"
const ToastDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = "ToastDescription"
interface ToastCloseProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const ToastClose = React.forwardRef<HTMLButtonElement, ToastCloseProps>(
({ className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100",
className
)}
{...props}
>
<X className="h-4 w-4" />
</button>
)
}
)
ToastClose.displayName = "ToastClose"
export {
ToastProvider,
useToast,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
toastVariants,
}
export type { ToastMessage, ToastVariant }