diff --git a/apps/dashboard/app/(app)/layout.tsx b/apps/dashboard/app/(app)/layout.tsx index 50f8c42862..6febe18a33 100644 --- a/apps/dashboard/app/(app)/layout.tsx +++ b/apps/dashboard/app/(app)/layout.tsx @@ -1,11 +1,11 @@ +import { AppSidebar } from "@/components/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { getTenantId } from "@/lib/auth"; import { db } from "@/lib/db"; import { Empty } from "@unkey/ui"; import Link from "next/link"; import { redirect } from "next/navigation"; import { UsageBanner } from "./banner"; -import { DesktopSidebar } from "./desktop-sidebar"; -import { MobileSideBar } from "./mobile-sidebar"; interface LayoutProps { children: React.ReactNode; @@ -22,47 +22,49 @@ export default async function Layout({ children }: LayoutProps) { }, }, }); + if (!workspace) { return redirect("/apis"); } return ( -
+
- - -
- - -
-
- {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 ( + + + {segments.map((id, index) => { + // Calculate opacity based on position relative to current index + const distance = + (segments.length + index - segmentIndex) % segments.length; + const opacity = distance <= 4 ? 1 - distance * 0.2 : 0.1; + 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) =>