-
-
-
-
- {workspace.enabled ? (
- children
- ) : (
-
-
-
- This workspace is disabled
-
- Contact{" "}
-
- support@unkey.dev
-
-
-
+
+
+
+
+
+
+ {workspace.enabled ? (
+ children
+ ) : (
+
+
+
+ This workspace is disabled
+
+ Contact{" "}
+
+ support@unkey.dev
+
+
+
+
+ )}
- )}
-
-
+
+
+
);
diff --git a/apps/dashboard/app/(app)/workspace-navigations.tsx b/apps/dashboard/app/(app)/workspace-navigations.tsx
index f249a0ddf0..a95108eaea 100644
--- a/apps/dashboard/app/(app)/workspace-navigations.tsx
+++ b/apps/dashboard/app/(app)/workspace-navigations.tsx
@@ -1,23 +1,22 @@
import type { Workspace } from "@/lib/db";
+import { cn } from "../../lib/utils";
import {
- BookOpen,
- Cable,
- Crown,
Fingerprint,
Gauge,
- List,
- type LucideIcon,
- MonitorDot,
- Settings2,
- ShieldCheck,
- TableProperties,
-} from "lucide-react";
-import { cn } from "../../lib/utils";
+ Gear,
+ BookBookmark,
+ InputSearch,
+ Layers3,
+ Nodes,
+ ShieldKey,
+ Sparkle3,
+ Grid,
+} from "@unkey/icons";
-type NavItem = {
+export type NavItem = {
disabled?: boolean;
tooltip?: string;
- icon: LucideIcon | React.ElementType;
+ icon: React.ElementType;
href: string;
external?: boolean;
label: string;
@@ -43,11 +42,14 @@ const DiscordIcon = () => (
);
-const Tag: React.FC<{ label: string; className?: string }> = ({ label, className }) => (
+const Tag: React.FC<{ label: string; className?: string }> = ({
+ label,
+ className,
+}) => (
{label}
@@ -56,11 +58,11 @@ const Tag: React.FC<{ label: string; className?: string }> = ({ label, className
export const createWorkspaceNavigation = (
workspace: Pick
,
- segments: string[],
+ segments: string[]
) => {
return [
{
- icon: Cable,
+ icon: Nodes,
href: "/apis",
label: "APIs",
active: segments.at(0) === "apis",
@@ -72,34 +74,34 @@ export const createWorkspaceNavigation = (
active: segments.at(0) === "ratelimits",
},
{
- icon: ShieldCheck,
+ icon: ShieldKey,
label: "Authorization",
href: "/authorization/roles",
active: segments.some((s) => s === "authorization"),
},
{
- icon: List,
+ icon: InputSearch,
href: "/audit",
label: "Audit Log",
active: segments.at(0) === "audit",
},
{
- icon: MonitorDot,
+ icon: Grid,
href: "/monitors/verifications",
label: "Monitors",
active: segments.at(0) === "verifications",
hidden: !workspace.features.webhooks,
},
{
- icon: TableProperties,
+ icon: Layers3,
href: "/logs",
label: "Logs",
active: segments.at(0) === "logs",
tag: ,
},
{
- icon: Crown,
+ icon: Sparkle3,
href: "/success",
label: "Success",
active: segments.at(0) === "success",
@@ -114,7 +116,7 @@ export const createWorkspaceNavigation = (
hidden: !workspace.betaFeatures.identities,
},
{
- icon: Settings2,
+ icon: Gear,
href: "/settings/general",
label: "Settings",
active: segments.at(0) === "settings",
@@ -124,7 +126,7 @@ export const createWorkspaceNavigation = (
export const resourcesNavigation: NavItem[] = [
{
- icon: BookOpen,
+ icon: BookBookmark,
href: "https://unkey.dev/docs",
external: true,
label: "Docs",
diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json
index 75a2a21e3c..fc150981a1 100644
--- a/apps/dashboard/components.json
+++ b/apps/dashboard/components.json
@@ -2,14 +2,20 @@
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
+ "tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "@/styles/tailwind/tailwind.css",
"baseColor": "zinc",
- "cssVariables": true
+ "cssVariables": true,
+ "prefix": ""
},
"aliases": {
"components": "@/components",
- "utils": "@/lib/utils"
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
}
}
+
diff --git a/apps/dashboard/components/app-sidebar.tsx b/apps/dashboard/components/app-sidebar.tsx
new file mode 100644
index 0000000000..4435bf9099
--- /dev/null
+++ b/apps/dashboard/components/app-sidebar.tsx
@@ -0,0 +1,307 @@
+"use client";
+
+import { WorkspaceSwitcher } from "@/app/(app)/team-switcher";
+import { UserButton } from "@/app/(app)/user-button";
+import {
+ createWorkspaceNavigation,
+ resourcesNavigation,
+ type NavItem,
+} from "@/app/(app)/workspace-navigations";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarRail,
+} from "@/components/ui/sidebar";
+import { useDelayLoader } from "@/hooks/useDelayLoader";
+import type { Workspace } from "@/lib/db";
+import { cn } from "@/lib/utils";
+import { ChevronRight } from "lucide-react";
+import Link from "next/link";
+import { useRouter, useSelectedLayoutSegments } from "next/navigation";
+import { useEffect, useState, useTransition } from "react";
+
+const getButtonStyles = (isActive?: boolean, showLoader?: boolean) => {
+ return cn(
+ "flex items-center group text-[13px] font-medium text-accent-12 hover:bg-grayA-3 hover:text-accent-12 justify-start active:border focus:ring-2 w-full text-left",
+ "rounded-lg transition-colors focus-visible:ring-1 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 disabled:cursor-not-allowed outline-none",
+ "focus:border-grayA-12 focus:ring-gray-6 focus-visible:outline-none focus:ring-offset-0 drop-shadow-button",
+ isActive ? "bg-grayA-3 text-accent-12" : "",
+ showLoader ? "bg-grayA-3" : ""
+ );
+};
+
+// Function to create navigation items that can have sub-items
+// Required in the following iterations
+const createNestedNavigation = (
+ workspace: Pick,
+ segments: string[]
+): (NavItem & { items?: NavItem[] })[] => {
+ // Get the base navigation items
+ const baseNav = createWorkspaceNavigation(workspace, segments);
+ return baseNav;
+};
+
+// Navigation item renderer that supports both regular and nested items
+const NavItems = ({ item }: { item: NavItem & { items?: NavItem[] } }) => {
+ const [isPending, startTransition] = useTransition();
+ const showLoader = useDelayLoader(isPending);
+ const router = useRouter();
+
+ // For loading indicators in sub-items
+ const [subPending, setSubPending] = useState>({});
+
+ const Icon = item.icon;
+
+ // Render a flat navigation item (no sub-items)
+ if (!item.items || item.items.length === 0) {
+ return (
+
+ {
+ if (!item.external) {
+ startTransition(() => {
+ router.push(item.href);
+ });
+ }
+ }}
+ target={item.external ? "_blank" : undefined}
+ >
+
+ {showLoader ? : }
+ {item.label}
+
+
+
+ );
+ }
+
+ // Render a collapsible navigation item with sub-items
+ return (
+
+
+
+
+ {showLoader ? : }
+ {item.label}
+
+
+
+
+
+ {item.items.map((subItem) => (
+
+ {
+ if (!subItem.external) {
+ // Track loading state for this specific sub-item
+ const updatedPending = { ...subPending };
+ updatedPending[subItem.label] = true;
+ setSubPending(updatedPending);
+
+ startTransition(() => {
+ router.push(subItem.href);
+
+ // Reset loading state after transition
+ setTimeout(() => {
+ const resetPending = { ...subPending };
+ resetPending[subItem.label] = false;
+ setSubPending(resetPending);
+ }, 300);
+ });
+ }
+ }}
+ target={subItem.external ? "_blank" : undefined}
+ >
+
+ {subPending[subItem.label] ? (
+
+ ) : subItem.icon ? (
+
+ ) : null}
+ {subItem.label}
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+// AppSidebar component
+export function AppSidebar({
+ ...props
+}: React.ComponentProps & { workspace: Workspace }) {
+ const segments = useSelectedLayoutSegments() ?? [];
+ const navItems = createNestedNavigation(props.workspace, segments);
+
+ return (
+
+
+
+
+
+
+ WORKSPACE
+
+ {navItems.map((item) => (
+
+ ))}
+
+
+
+ RESOURCES
+
+ {resourcesNavigation.map((item) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+const AnimatedLoadingSpinner = () => {
+ const [segmentIndex, setSegmentIndex] = useState(0);
+ // Each segment ID in the order they should light up
+ const segments = [
+ "segment-1", // Right top
+ "segment-2", // Right
+ "segment-3", // Right bottom
+ "segment-4", // Bottom
+ "segment-5", // Left bottom
+ "segment-6", // Left
+ "segment-7", // Left top
+ "segment-8", // Top
+ ];
+
+ useEffect(() => {
+ // Animate the segments in sequence
+ const timer = setInterval(() => {
+ setSegmentIndex((prevIndex) => (prevIndex + 1) % segments.length);
+ }, 125); // 125ms per segment = 1s for full rotation
+ return () => clearInterval(timer);
+ }, []);
+
+ return (
+
+ );
+};
+
+// Helper function to get path data for each segment
+function getPathForSegment(index: number) {
+ const paths = [
+ "M13.162,3.82c-.148,0-.299-.044-.431-.136-.784-.552-1.662-.915-2.61-1.08-.407-.071-.681-.459-.61-.867,.071-.408,.459-.684,.868-.61,1.167,.203,2.248,.65,3.216,1.33,.339,.238,.42,.706,.182,1.045-.146,.208-.378,.319-.614,.319Z",
+ "M16.136,8.5c-.357,0-.675-.257-.738-.622-.163-.942-.527-1.82-1.082-2.608-.238-.339-.157-.807,.182-1.045,.34-.239,.809-.156,1.045,.182,.683,.97,1.132,2.052,1.334,3.214,.07,.408-.203,.796-.611,.867-.043,.008-.086,.011-.129,.011Z",
+ "M14.93,13.913c-.148,0-.299-.044-.431-.137-.339-.238-.42-.706-.182-1.045,.551-.784,.914-1.662,1.078-2.609,.071-.408,.466-.684,.867-.611,.408,.071,.682,.459,.611,.867-.203,1.167-.65,2.25-1.33,3.216-.146,.208-.378,.318-.614,.318Z",
+ "M10.249,16.887c-.357,0-.675-.257-.738-.621-.07-.408,.202-.797,.61-.868,.945-.165,1.822-.529,2.608-1.082,.34-.238,.807-.156,1.045,.182,.238,.338,.157,.807-.182,1.045-.968,.682-2.05,1.13-3.214,1.333-.044,.008-.087,.011-.13,.011Z",
+ "M7.751,16.885c-.043,0-.086-.003-.13-.011-1.167-.203-2.249-.651-3.216-1.33-.339-.238-.42-.706-.182-1.045,.236-.339,.702-.421,1.045-.183,.784,.551,1.662,.915,2.61,1.08,.408,.071,.681,.459,.61,.868-.063,.364-.381,.621-.738,.621Z",
+ "M3.072,13.911c-.236,0-.469-.111-.614-.318-.683-.97-1.132-2.052-1.334-3.214-.07-.408,.203-.796,.611-.867,.403-.073,.796,.202,.867,.61,.163,.942,.527,1.82,1.082,2.608,.238,.339,.157,.807-.182,1.045-.131,.092-.282,.137-.431,.137Z",
+ "M1.866,8.5c-.043,0-.086-.003-.129-.011-.408-.071-.682-.459-.611-.867,.203-1.167,.65-2.25,1.33-3.216,.236-.339,.703-.422,1.045-.182,.339,.238,.42,.706,.182,1.045-.551,.784-.914,1.662-1.078,2.609-.063,.365-.381,.622-.738,.622Z",
+ "M4.84,3.821c-.236,0-.468-.111-.614-.318-.238-.338-.157-.807,.182-1.045,.968-.682,2.05-1.13,3.214-1.333,.41-.072,.797,.202,.868,.61,.07,.408-.202,.797-.61,.868-.945,.165-1.822,.529-2.608,1.082-.131,.092-.282,.137-.431,.137Z",
+ ];
+ return paths[index];
+}
+
+// Add CSS for the spin-slow animation
+if (typeof document !== "undefined") {
+ const style = document.createElement("style");
+ style.textContent = `
+ @media (prefers-reduced-motion: reduce) {
+ [data-prefers-reduced-motion="respect-motion-preference"] {
+ animation: none !important;
+ transition: none !important;
+ }
+ }
+
+ @keyframes spin-slow {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ .animate-spin-slow {
+ animation: spin-slow 1.5s linear infinite;
+ }
+ `;
+ document.head.appendChild(style);
+}
diff --git a/apps/dashboard/components/ui/breadcrumb.tsx b/apps/dashboard/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000000..60e6c96f72
--- /dev/null
+++ b/apps/dashboard/components/ui/breadcrumb.tsx
@@ -0,0 +1,115 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode
+ }
+>(({ ...props }, ref) => )
+Breadcrumb.displayName = "Breadcrumb"
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbList.displayName = "BreadcrumbList"
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbItem.displayName = "BreadcrumbItem"
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+})
+BreadcrumbLink.displayName = "BreadcrumbLink"
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbPage.displayName = "BreadcrumbPage"
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:w-3.5 [&>svg]:h-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+)
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+)
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/apps/dashboard/components/ui/button.tsx b/apps/dashboard/components/ui/button.tsx
new file mode 100644
index 0000000000..65d4fcd9ca
--- /dev/null
+++ b/apps/dashboard/components/ui/button.tsx
@@ -0,0 +1,57 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/apps/dashboard/components/ui/collapsible.tsx b/apps/dashboard/components/ui/collapsible.tsx
index cb003d1756..9fa48946af 100644
--- a/apps/dashboard/components/ui/collapsible.tsx
+++ b/apps/dashboard/components/ui/collapsible.tsx
@@ -1,11 +1,11 @@
-"use client";
+"use client"
-import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
-const Collapsible = CollapsiblePrimitive.Root;
+const Collapsible = CollapsiblePrimitive.Root
-const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
-const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
-export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/apps/dashboard/components/ui/sidebar.tsx b/apps/dashboard/components/ui/sidebar.tsx
new file mode 100644
index 0000000000..a76c4d5bc9
--- /dev/null
+++ b/apps/dashboard/components/ui/sidebar.tsx
@@ -0,0 +1,773 @@
+"use client";
+
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { type VariantProps, cva } from "class-variance-authority";
+import { PanelLeft } from "lucide-react";
+
+import { useIsMobile } from "@/hooks/use-mobile";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Separator } from "@/components/ui/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state";
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = "16rem";
+const SIDEBAR_WIDTH_MOBILE = "18rem";
+const SIDEBAR_WIDTH_ICON = "3rem";
+const SIDEBAR_KEYBOARD_SHORTCUT = "b";
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed";
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.");
+ }
+
+ return context;
+}
+
+const SidebarProvider = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }
+>(
+ (
+ {
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open]
+ );
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile
+ ? setOpenMobile((open) => !open)
+ : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [toggleSidebar]);
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed";
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+);
+SidebarProvider.displayName = "SidebarProvider";
+
+const Sidebar = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ side?: "left" | "right";
+ variant?: "sidebar" | "floating" | "inset";
+ collapsible?: "offcanvas" | "icon" | "none";
+ }
+>(
+ (
+ {
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+ }
+);
+Sidebar.displayName = "Sidebar";
+
+const SidebarTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, onClick, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+});
+SidebarTrigger.displayName = "SidebarTrigger";
+
+const SidebarRail = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button">
+>(({ className, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+});
+SidebarRail.displayName = "SidebarRail";
+
+const SidebarInset = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"main">
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarInset.displayName = "SidebarInset";
+
+const SidebarInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarInput.displayName = "SidebarInput";
+
+const SidebarHeader = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarHeader.displayName = "SidebarHeader";
+
+const SidebarFooter = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarFooter.displayName = "SidebarFooter";
+
+const SidebarSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarSeparator.displayName = "SidebarSeparator";
+
+const SidebarContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarContent.displayName = "SidebarContent";
+
+const SidebarGroup = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarGroup.displayName = "SidebarGroup";
+
+const SidebarGroupLabel = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupLabel.displayName = "SidebarGroupLabel";
+
+const SidebarGroupAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 after:md:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupAction.displayName = "SidebarGroupAction";
+
+const SidebarGroupContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => (
+
+));
+SidebarGroupContent.displayName = "SidebarGroupContent";
+
+const SidebarMenu = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenu.displayName = "SidebarMenu";
+
+const SidebarMenuItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenuItem.displayName = "SidebarMenuItem";
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 px-4 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+const SidebarMenuButton = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+ } & VariantProps
+>(
+ (
+ {
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const Comp = asChild ? Slot : "button";
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+ }
+);
+SidebarMenuButton.displayName = "SidebarMenuButton";
+
+const SidebarMenuAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+ }
+>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 after:md:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuAction.displayName = "SidebarMenuAction";
+
+const SidebarMenuBadge = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenuBadge.displayName = "SidebarMenuBadge";
+
+const SidebarMenuSkeleton = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ showIcon?: boolean;
+ }
+>(({ className, showIcon = false, ...props }, ref) => {
+ // Random width between 50 to 90%.
+ const width = React.useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`;
+ }, []);
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ );
+});
+SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
+
+const SidebarMenuSub = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+));
+SidebarMenuSub.displayName = "SidebarMenuSub";
+
+const SidebarMenuSubItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ ...props }, ref) => );
+SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
+
+const SidebarMenuSubButton = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentProps<"a"> & {
+ asChild?: boolean;
+ size?: "sm" | "md";
+ isActive?: boolean;
+ }
+>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+ span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+};
diff --git a/apps/dashboard/hooks/use-mobile.tsx b/apps/dashboard/hooks/use-mobile.tsx
new file mode 100644
index 0000000000..2b0fe1dfef
--- /dev/null
+++ b/apps/dashboard/hooks/use-mobile.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 4c4708afe7..be18d4db71 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -97,7 +97,7 @@
"stripe": "^14.23.0",
"superjson": "^2.2.1",
"svix": "^1.37.0",
- "tailwind-merge": "^2.2.2",
+ "tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.3",
"tailwindcss-animate": "^1.0.7",
"trpc-tools": "^0.12.0",
diff --git a/internal/ui/tailwind.config.js b/internal/ui/tailwind.config.js
index 38b03c854b..f7fc4ba6f0 100644
--- a/internal/ui/tailwind.config.js
+++ b/internal/ui/tailwind.config.js
@@ -76,6 +76,7 @@ function generateRadixColors() {
"errorA", // Added tomatoA
"feature",
"accent",
+ "base",
];
const colors = {};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a250db6c4c..a9e3adfb1a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -496,7 +496,7 @@ importers:
specifier: ^1.37.0
version: 1.37.0
tailwind-merge:
- specifier: ^2.2.2
+ specifier: ^2.5.4
version: 2.5.4
tailwindcss:
specifier: ^3.4.3
@@ -1048,7 +1048,7 @@ importers:
devDependencies:
checkly:
specifier: latest
- version: 4.15.0(@types/node@20.14.9)(typescript@5.5.3)
+ version: 4.19.1(@types/node@20.14.9)(typescript@5.5.3)
ts-node:
specifier: 10.9.1
version: 10.9.1(@types/node@20.14.9)(typescript@5.5.3)
@@ -12894,8 +12894,8 @@ packages:
get-func-name: 2.0.2
dev: true
- /checkly@4.15.0(@types/node@20.14.9)(typescript@5.5.3):
- resolution: {integrity: sha512-uGvz/3BBL/fs0y/jCylcRIP8XMOn65OXyBsg6NJB+7UKw2SHvpRDAR2frgRYBXKrKXrmrrhaCNEtNudvnJ/iaA==}
+ /checkly@4.19.1(@types/node@20.14.9)(typescript@5.5.3):
+ resolution: {integrity: sha512-KtUzvKWvY4Pa1O2is7s4UK9w3X4G8jVsYntdXLDzwfajsg22bq4qa+n3w2uZehGmbIrUmL638alG76XrRQ5PDQ==}
engines: {node: '>=16.0.0'}
hasBin: true
dependencies: