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:
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