svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className,
+ )}
+ data-slot="avatar-group-count"
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarBadge,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarImage,
+};
diff --git a/packages/vitnode/src/components/ui/badge.tsx b/packages/vitnode/src/components/ui/badge.tsx
index 17d21186e..6754970b8 100644
--- a/packages/vitnode/src/components/ui/badge.tsx
+++ b/packages/vitnode/src/components/ui/badge.tsx
@@ -1,23 +1,24 @@
-import type * as React from "react";
-
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
+import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ "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]:pe-1.5 has-data-[icon=inline-start]:ps-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:
- "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
- "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
- "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ "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:
- "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ "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: {
@@ -28,7 +29,7 @@ const badgeVariants = cva(
function Badge({
className,
- variant,
+ variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
@@ -39,6 +40,7 @@ function Badge({
);
diff --git a/packages/vitnode/src/components/ui/breadcrumb.tsx b/packages/vitnode/src/components/ui/breadcrumb.tsx
new file mode 100644
index 000000000..e3a023373
--- /dev/null
+++ b/packages/vitnode/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,119 @@
+import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
+import { Slot } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ );
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
svg]:size-3.5", className)}
+ data-slot="breadcrumb-separator"
+ role="presentation"
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
svg]:size-4",
+ className,
+ )}
+ data-slot="breadcrumb-ellipsis"
+ role="presentation"
+ {...props}
+ >
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbEllipsis,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+};
diff --git a/packages/vitnode/src/components/ui/button-group.tsx b/packages/vitnode/src/components/ui/button-group.tsx
new file mode 100644
index 000000000..e46e7a49e
--- /dev/null
+++ b/packages/vitnode/src/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+const buttonGroupVariants = cva(
+ "group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-s-none [&>*:not(:first-child)]:border-s-0 [&>*:not(:last-child)]:rounded-e-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-e-md!",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md!",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ },
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : "div";
+
+ return (
+
+ );
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+};
diff --git a/packages/vitnode/src/components/ui/button.tsx b/packages/vitnode/src/components/ui/button.tsx
index f5bf1d54d..a36ade806 100644
--- a/packages/vitnode/src/components/ui/button.tsx
+++ b/packages/vitnode/src/components/ui/button.tsx
@@ -5,29 +5,33 @@ import { cva, type VariantProps } from "class-variance-authority";
import { ClientButton } from "./button-client";
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer overflow-hidden active:scale-[0.98] select-none",
+ "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
{
variants: {
variant: {
- default:
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
- destructive:
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
- destructiveGhost:
- "hover:bg-destructive/20 text-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ default:
+ "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
+ lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-9",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
+ "icon-lg": "size-10",
},
},
defaultVariants: {
@@ -40,7 +44,7 @@ const buttonVariants = cva(
type ButtonProps = React.ComponentProps<"button"> &
VariantProps &
(
- | { "aria-label": string; size: "icon" }
+ | { "aria-label": string; size: "icon" | "icon-lg" | "icon-sm" | "icon-xs" }
| { "aria-label"?: string; size?: "default" | "lg" | "sm" }
) & {
asChild?: boolean;
diff --git a/packages/vitnode/src/components/ui/card.tsx b/packages/vitnode/src/components/ui/card.tsx
index 70d080889..ce5e325fc 100644
--- a/packages/vitnode/src/components/ui/card.tsx
+++ b/packages/vitnode/src/components/ui/card.tsx
@@ -1,14 +1,19 @@
-import type * as React from "react";
+import * as React from "react";
import { cn } from "@/lib/utils";
-function Card({ className, ...props }: React.ComponentProps<"div">) {
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
+ data-size={size}
data-slot="card"
{...props}
/>
@@ -19,7 +24,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -64,7 +72,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -74,7 +82,10 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
diff --git a/packages/vitnode/src/components/ui/carousel.tsx b/packages/vitnode/src/components/ui/carousel.tsx
new file mode 100644
index 000000000..9b8f9af74
--- /dev/null
+++ b/packages/vitnode/src/components/ui/carousel.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react";
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import { useTranslations } from "next-intl";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters
;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+interface CarouselProps {
+ opts?: CarouselOptions;
+ orientation?: "horizontal" | "vertical";
+ plugins?: CarouselPlugin;
+ setApi?: (api: CarouselApi) => void;
+}
+
+type CarouselContextProps = CarouselProps & {
+ api: ReturnType[1];
+ canScrollNext: boolean;
+ canScrollPrev: boolean;
+ carouselRef: ReturnType[0];
+ scrollNext: () => void;
+ scrollPrev: () => void;
+};
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.use(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: CarouselProps & React.ComponentProps<"div">) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ // eslint-disable-next-line @eslint-react/set-state-in-effect
+ setCanScrollPrev(api.canScrollPrev());
+ // eslint-disable-next-line @eslint-react/set-state-in-effect
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ // eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon-sm",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+ const t = useTranslations("core.global");
+
+ return (
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon-sm",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+ const t = useTranslations("core.global");
+
+ return (
+
+ );
+}
+
+export {
+ Carousel,
+ type CarouselApi,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+ useCarousel,
+};
diff --git a/packages/vitnode/src/components/ui/chart.tsx b/packages/vitnode/src/components/ui/chart.tsx
new file mode 100644
index 000000000..0706cbdd5
--- /dev/null
+++ b/packages/vitnode/src/components/ui/chart.tsx
@@ -0,0 +1,379 @@
+"use client";
+
+import type { TooltipValueType } from "recharts";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
+type TooltipNameType = number | string;
+
+export type ChartConfig = Record<
+ string,
+ (
+ | { color?: never; theme: Record }
+ | { color?: string; theme?: never }
+ ) & {
+ icon?: React.ComponentType;
+ label?: React.ReactNode;
+ }
+>;
+
+interface ChartContextProps {
+ config: ChartConfig;
+}
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.use(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ initialDimension = INITIAL_DIMENSION,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"];
+ config: ChartConfig;
+ initialDimension?: {
+ height: number;
+ width: number;
+ };
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { config: ChartConfig; id: string }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme ?? config.color,
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+