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:
70
src/components/drinks/add-to-wishlist-button.tsx
Normal file
70
src/components/drinks/add-to-wishlist-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
256
src/components/drinks/ai-drink-search.tsx
Normal file
256
src/components/drinks/ai-drink-search.tsx
Normal 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">
|
||||
“{activeQuery}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
88
src/components/drinks/drink-card.tsx
Normal file
88
src/components/drinks/drink-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
src/components/drinks/drink-detail-actions.tsx
Normal file
149
src/components/drinks/drink-detail-actions.tsx
Normal 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 “{drinkName}”? 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
src/components/drinks/drink-filters.tsx
Normal file
74
src/components/drinks/drink-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
src/components/drinks/drink-form.tsx
Normal file
175
src/components/drinks/drink-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
src/components/layout/bottom-nav.tsx
Normal file
43
src/components/layout/bottom-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
src/components/layout/header.tsx
Normal file
14
src/components/layout/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/components/layout/sidebar.tsx
Normal file
91
src/components/layout/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/components/providers.tsx
Normal file
27
src/components/providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
src/components/ratings/rating-display.tsx
Normal file
69
src/components/ratings/rating-display.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
src/components/ratings/rating-form.tsx
Normal file
164
src/components/ratings/rating-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
131
src/components/scan/camera-capture.tsx
Normal file
131
src/components/scan/camera-capture.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
src/components/scan/menu-item-card.tsx
Normal file
170
src/components/scan/menu-item-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
src/components/scan/photo-upload.tsx
Normal file
172
src/components/scan/photo-upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
src/components/settings/backup-restore.tsx
Normal file
119
src/components/settings/backup-restore.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
240
src/components/settings/restore-dialog.tsx
Normal file
240
src/components/settings/restore-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/components/sharing/share-button.tsx
Normal file
20
src/components/sharing/share-button.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
189
src/components/sharing/share-dialog.tsx
Normal file
189
src/components/sharing/share-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal 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 }
|
||||
53
src/components/ui/button.tsx
Normal file
53
src/components/ui/button.tsx
Normal 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 }
|
||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal 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 }
|
||||
203
src/components/ui/dialog.tsx
Normal file
203
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
211
src/components/ui/dropdown-menu.tsx
Normal file
211
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal 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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
49
src/components/ui/select.tsx
Normal file
49
src/components/ui/select.tsx
Normal 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 }
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal 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 }
|
||||
16
src/components/ui/skeleton.tsx
Normal file
16
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal 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
232
src/components/ui/toast.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user