diff --git a/package.json b/package.json index 900a1d9..3162b36 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "minio": "^8.0.7", "next": "^15.5.15", "next-auth": "5.0.0-beta.31", + "next-themes": "^0.4.6", "pino": "^10.3.1", "postgres": "^3.4.9", "react": "^19.2.5", @@ -100,6 +101,7 @@ "react-resizable": "^3.1.3", "server-only": "^0.0.1", "shadcn": "^4.7.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "web-push": "^3.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 999c9b4..49392af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: next-auth: specifier: 5.0.0-beta.31 version: 5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) pino: specifier: ^10.3.1 version: 10.3.1 @@ -86,6 +89,9 @@ importers: shadcn: specifier: ^4.7.0 version: 4.7.0(@types/node@24.12.4)(typescript@5.9.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -3963,6 +3969,12 @@ packages: nodemailer: optional: true + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.5.15: resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -4619,6 +4631,12 @@ packages: sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8733,6 +8751,11 @@ snapshots: next: 15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 + next-themes@0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 15.5.15 @@ -9572,6 +9595,11 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..f50d898 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..17f3ffd --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,93 @@ +"use client"; + +import * as React from "react"; +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ); +} + +function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className, + )} + {...props} + /> + ); +} + +export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..e1997af --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: "badge", + variant, + }, + }); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 0000000..86c0959 --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,94 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className, + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..f2c55c8 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) { + return ( + + ); +} + +export { Separator }; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..26bc059 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..7cbe96b --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; +import { + CircleCheckIcon, + InfoIcon, + TriangleAlertIcon, + OctagonXIcon, + Loader2Icon, +} from "lucide-react"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..abfc082 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Switch as SwitchPrimitive } from "@base-ui/react/switch"; + +import { cn } from "@/lib/utils"; + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default"; +}) { + return ( + + + + ); +} + +export { Switch }; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..31c14f1 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) { + return ( + + ); +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ); +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..fab89f1 --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,87 @@ +"use client"; + +import * as React from "react"; +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; +import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"; +import { type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { toggleVariants } from "@/components/ui/toggle"; + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number; + orientation?: "horizontal" | "vertical"; + } +>({ + size: "default", + variant: "default", + spacing: 2, + orientation: "horizontal", +}); + +function ToggleGroup({ + className, + variant, + size, + spacing = 2, + orientation = "horizontal", + children, + ...props +}: ToggleGroupPrimitive.Props & + VariantProps & { + spacing?: number; + orientation?: "horizontal" | "vertical"; + }) { + return ( + + + {children} + + + ); +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +} + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 0000000..f61ced5 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const toggleVariants = cva( + "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent hover:bg-muted", + }, + size: { + default: + "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Toggle({ + className, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Toggle, toggleVariants };