Browse Source

feat(dashboard): Add dev mode info to nav menu

Michael Bromley 5 months ago
parent
commit
867655e635

+ 2 - 7
packages/dashboard/src/lib/components/layout/app-layout.tsx

@@ -1,11 +1,10 @@
 import { AppSidebar } from '@/vdb/components/layout/app-sidebar.js';
+import { DevModeIndicator } from '@/vdb/components/layout/dev-mode-indicator.js';
 import { GeneratedBreadcrumbs } from '@/vdb/components/layout/generated-breadcrumbs.js';
 import { PrereleasePopup } from '@/vdb/components/layout/prerelease-popup.js';
-import { Badge } from '@/vdb/components/ui/badge.js';
 import { Separator } from '@/vdb/components/ui/separator.js';
 import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/vdb/components/ui/sidebar.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
-import { Trans } from '@/vdb/lib/trans.js';
 import { Outlet } from '@tanstack/react-router';
 import { Alerts } from '../shared/alerts.js';
 
@@ -24,11 +23,7 @@ export function AppLayout() {
                                 <GeneratedBreadcrumbs />
                             </div>
                             <div className="flex items-center justify-end gap-2">
-                                {settings.devMode && (
-                                    <Badge variant="destructive">
-                                        <Trans>Dev Mode</Trans>
-                                    </Badge>
-                                )}
+                                {settings.devMode && <DevModeIndicator />}
                                 <Alerts />
                             </div>
                         </div>

+ 18 - 0
packages/dashboard/src/lib/components/layout/dev-mode-indicator.tsx

@@ -0,0 +1,18 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { CodeXmlIcon, XIcon } from 'lucide-react';
+
+export function DevModeIndicator() {
+    const { setDevMode } = useUserSettings();
+    return (
+        <Badge className="bg-dev-mode text-background">
+            <CodeXmlIcon className="w-6 h-6" />
+            <Trans>Dev Mode</Trans>
+            <Button variant="ghost" size="icon-xs" onClick={() => setDevMode(false)}>
+                <XIcon className="w-4 h-4" />
+            </Button>
+        </Badge>
+    );
+}

+ 107 - 0
packages/dashboard/src/lib/components/layout/nav-item-wrapper.tsx

@@ -0,0 +1,107 @@
+import { CopyableText } from '@/vdb/components/shared/copyable-text.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
+import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
+import { cn } from '@/vdb/lib/utils.js';
+import React, { useEffect, useState } from 'react';
+import { DevModeButton } from '../../framework/layout-engine/dev-mode-button.js';
+
+// Singleton state for hover tracking
+let globalHoveredNavId: string | null = null;
+const navHoverListeners: Set<(id: string | null) => void> = new Set();
+
+const setGlobalHoveredNavId = (id: string | null) => {
+    globalHoveredNavId = id;
+    navHoverListeners.forEach(listener => listener(id));
+};
+
+export interface NavItemWrapperProps {
+    children: React.ReactNode;
+    locationId: string;
+    order?: number;
+    parentLocationId?: string;
+    offset?: boolean;
+}
+
+export function NavItemWrapper({
+    children,
+    locationId,
+    order,
+    parentLocationId,
+    offset,
+}: Readonly<NavItemWrapperProps>) {
+    const { settings } = useUserSettings();
+    const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+    const [hoveredId, setHoveredId] = useState<string | null>(globalHoveredNavId);
+
+    const isHovered = hoveredId === locationId;
+
+    // Subscribe to global hover changes
+    useEffect(() => {
+        const listener = (newHoveredId: string | null) => {
+            setHoveredId(newHoveredId);
+        };
+        navHoverListeners.add(listener);
+        return () => {
+            navHoverListeners.delete(listener);
+        };
+    }, []);
+
+    const setHoverId = (id: string | null) => {
+        setGlobalHoveredNavId(id);
+    };
+
+    const handleMouseEnter = () => {
+        setHoverId(locationId);
+    };
+
+    const handleMouseLeave = () => {
+        // If we have a parent, fall back to the parent on mouse leave
+        // Otherwise, clear the hover
+        setHoverId(parentLocationId || null);
+    };
+
+    if (settings.devMode) {
+        return (
+            <div
+                className={cn(
+                    'ring-2 ring-transparent rounded-md transition-all delay-50 relative',
+                    isHovered || isPopoverOpen ? 'ring-dev-mode ring-offset-1' : '',
+                )}
+                onMouseEnter={handleMouseEnter}
+                onMouseLeave={handleMouseLeave}
+            >
+                <div
+                    className={cn(
+                        `absolute right-0 transition-all delay-50 z-10`,
+                        isHovered || isPopoverOpen ? 'visible' : 'invisible',
+                        offset ? 'right-[26px] top-[3px]' : 'right-[3px] top-0.5 ',
+                    )}
+                >
+                    <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
+                        <PopoverTrigger asChild>
+                            <DevModeButton className={`h-6 w-6`} />
+                        </PopoverTrigger>
+                        <PopoverContent className="w-48 p-3">
+                            <div className="space-y-2">
+                                <div className="space-y-1">
+                                    <div className="text-xs">
+                                        <div className="text-muted-foreground mb-0.5">locationId</div>
+                                        <CopyableText text={locationId} />
+                                    </div>
+                                    {order !== undefined && (
+                                        <div className="text-xs">
+                                            <div className="text-muted-foreground mb-0.5">order</div>
+                                            <CopyableText text={order.toString()} />
+                                        </div>
+                                    )}
+                                </div>
+                            </div>
+                        </PopoverContent>
+                    </Popover>
+                </div>
+                {children}
+            </div>
+        );
+    }
+    return children;
+}

+ 110 - 85
packages/dashboard/src/lib/components/layout/nav-main.tsx

@@ -17,6 +17,7 @@ import {
 import { Link, useLocation } from '@tanstack/react-router';
 import { ChevronRight } from 'lucide-react';
 import * as React from 'react';
+import { NavItemWrapper } from './nav-item-wrapper.js';
 
 // Utility to sort items & sections by the optional `order` prop (ascending) and then alphabetically by title
 function sortByOrder<T extends { order?: number; title: string }>(a: T, b: T) {
@@ -85,53 +86,63 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
     const renderTopSection = (item: NavMenuSection | NavMenuItem) => {
         if ('url' in item) {
             return (
-                <SidebarMenuItem key={item.title}>
-                    <SidebarMenuButton tooltip={item.title} asChild isActive={location.pathname === item.url}>
-                        <Link to={item.url}>
-                            {item.icon && <item.icon />}
-                            <span>{item.title}</span>
-                        </Link>
-                    </SidebarMenuButton>
-                </SidebarMenuItem>
+                <NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
+                    <SidebarMenuItem>
+                        <SidebarMenuButton
+                            tooltip={item.title}
+                            asChild
+                            isActive={location.pathname === item.url}
+                        >
+                            <Link to={item.url}>
+                                {item.icon && <item.icon />}
+                                <span>{item.title}</span>
+                            </Link>
+                        </SidebarMenuButton>
+                    </SidebarMenuItem>
+                </NavItemWrapper>
             );
         }
 
         return (
-            <Collapsible
-                key={item.title}
-                asChild
-                defaultOpen={item.defaultOpen}
-                className="group/collapsible"
-            >
-                <SidebarMenuItem>
-                    <CollapsibleTrigger asChild>
-                        <SidebarMenuButton tooltip={item.title}>
-                            {item.icon && <item.icon />}
-                            <span>{item.title}</span>
-                            <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
-                        </SidebarMenuButton>
-                    </CollapsibleTrigger>
-                    <CollapsibleContent>
-                        <SidebarMenuSub>
-                            {item.items?.map(subItem => (
-                                <SidebarMenuSubItem key={subItem.title}>
-                                    <SidebarMenuSubButton
-                                        asChild
-                                        isActive={
-                                            location.pathname === subItem.url ||
-                                            location.pathname.startsWith(`${subItem.url}/`)
-                                        }
+            <NavItemWrapper key={item.title} locationId={item.id} order={item.order} offset={true}>
+                <Collapsible asChild defaultOpen={item.defaultOpen} className="group/collapsible">
+                    <SidebarMenuItem>
+                        <CollapsibleTrigger asChild>
+                            <SidebarMenuButton tooltip={item.title}>
+                                {item.icon && <item.icon />}
+                                <span>{item.title}</span>
+                                <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+                            </SidebarMenuButton>
+                        </CollapsibleTrigger>
+                        <CollapsibleContent>
+                            <SidebarMenuSub>
+                                {item.items?.map(subItem => (
+                                    <NavItemWrapper
+                                        key={subItem.title}
+                                        locationId={subItem.id}
+                                        order={subItem.order}
+                                        parentLocationId={item.id}
                                     >
-                                        <Link to={subItem.url}>
-                                            <span>{subItem.title}</span>
-                                        </Link>
-                                    </SidebarMenuSubButton>
-                                </SidebarMenuSubItem>
-                            ))}
-                        </SidebarMenuSub>
-                    </CollapsibleContent>
-                </SidebarMenuItem>
-            </Collapsible>
+                                        <SidebarMenuSubItem>
+                                            <SidebarMenuSubButton
+                                                asChild
+                                                isActive={
+                                                    location.pathname === subItem.url ||
+                                                    location.pathname.startsWith(`${subItem.url}/`)
+                                                }
+                                            >
+                                                <Link to={subItem.url}>
+                                                    <span>{subItem.title}</span>
+                                                </Link>
+                                            </SidebarMenuSubButton>
+                                        </SidebarMenuSubItem>
+                                    </NavItemWrapper>
+                                ))}
+                            </SidebarMenuSub>
+                        </CollapsibleContent>
+                    </SidebarMenuItem>
+                </Collapsible>
+            </NavItemWrapper>
         );
     };
 
@@ -139,53 +150,67 @@ export function NavMain({ items }: Readonly<{ items: Array<NavMenuSection | NavM
     const renderBottomSection = (item: NavMenuSection | NavMenuItem) => {
         if ('url' in item) {
             return (
-                <SidebarMenuItem key={item.title}>
-                    <SidebarMenuButton tooltip={item.title} asChild isActive={location.pathname === item.url}>
-                        <Link to={item.url}>
-                            {item.icon && <item.icon />}
-                            <span>{item.title}</span>
-                        </Link>
-                    </SidebarMenuButton>
-                </SidebarMenuItem>
+                <NavItemWrapper key={item.title} locationId={item.id} order={item.order}>
+                    <SidebarMenuItem>
+                        <SidebarMenuButton
+                            tooltip={item.title}
+                            asChild
+                            isActive={location.pathname === item.url}
+                        >
+                            <Link to={item.url}>
+                                {item.icon && <item.icon />}
+                                <span>{item.title}</span>
+                            </Link>
+                        </SidebarMenuButton>
+                    </SidebarMenuItem>
+                </NavItemWrapper>
             );
         }
         return (
-            <Collapsible
-                key={item.title}
-                asChild
-                open={openBottomSectionId === item.id}
-                onOpenChange={isOpen => handleBottomSectionToggle(item.id, isOpen)}
-                className="group/collapsible"
-            >
-                <SidebarMenuItem>
-                    <CollapsibleTrigger asChild>
-                        <SidebarMenuButton tooltip={item.title}>
-                            {item.icon && <item.icon />}
-                            <span>{item.title}</span>
-                            <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
-                        </SidebarMenuButton>
-                    </CollapsibleTrigger>
-                    <CollapsibleContent>
-                        <SidebarMenuSub>
-                            {item.items?.map(subItem => (
-                                <SidebarMenuSubItem key={subItem.title}>
-                                    <SidebarMenuSubButton
-                                        asChild
-                                        isActive={
-                                            location.pathname === subItem.url ||
-                                            location.pathname.startsWith(`${subItem.url}/`)
-                                        }
+            <NavItemWrapper key={item.title} locationId={item.id} order={item.order}>
+                <Collapsible
+                    asChild
+                    open={openBottomSectionId === item.id}
+                    onOpenChange={isOpen => handleBottomSectionToggle(item.id, isOpen)}
+                    className="group/collapsible"
+                >
+                    <SidebarMenuItem>
+                        <CollapsibleTrigger asChild>
+                            <SidebarMenuButton tooltip={item.title}>
+                                {item.icon && <item.icon />}
+                                <span>{item.title}</span>
+                                <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+                            </SidebarMenuButton>
+                        </CollapsibleTrigger>
+                        <CollapsibleContent>
+                            <SidebarMenuSub>
+                                {item.items?.map(subItem => (
+                                    <NavItemWrapper
+                                        key={subItem.title}
+                                        locationId={subItem.id}
+                                        order={subItem.order}
+                                        parentLocationId={item.id}
                                     >
-                                        <Link to={subItem.url}>
-                                            <span>{subItem.title}</span>
-                                        </Link>
-                                    </SidebarMenuSubButton>
-                                </SidebarMenuSubItem>
-                            ))}
-                        </SidebarMenuSub>
-                    </CollapsibleContent>
-                </SidebarMenuItem>
-            </Collapsible>
+                                        <SidebarMenuSubItem>
+                                            <SidebarMenuSubButton
+                                                asChild
+                                                isActive={
+                                                    location.pathname === subItem.url ||
+                                                    location.pathname.startsWith(`${subItem.url}/`)
+                                                }
+                                            >
+                                                <Link to={subItem.url}>
+                                                    <span>{subItem.title}</span>
+                                                </Link>
+                                            </SidebarMenuSubButton>
+                                        </SidebarMenuSubItem>
+                                    </NavItemWrapper>
+                                ))}
+                            </SidebarMenuSub>
+                        </CollapsibleContent>
+                    </SidebarMenuItem>
+                </Collapsible>
+            </NavItemWrapper>
         );
     };
 

+ 24 - 0
packages/dashboard/src/lib/framework/layout-engine/dev-mode-button.tsx

@@ -0,0 +1,24 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { cn } from '@/vdb/lib/utils.js';
+import { CodeXmlIcon } from 'lucide-react';
+import { forwardRef } from 'react';
+
+export const DevModeButton = forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
+    (props, ref) => {
+        const { className, ...rest } = props;
+        return (
+            <Button
+                ref={ref}
+                variant="secondary"
+                size="icon"
+                className={cn(
+                    'h-8 w-8 rounded-full bg-dev-mode/20 hover:bg-dev-mode/30 border border-dev-mode/20 shadow-sm',
+                    className,
+                )}
+                {...rest}
+            >
+                <CodeXmlIcon className="text-dev-mode w-4 h-4" />
+            </Button>
+        );
+    },
+);

+ 2 - 9
packages/dashboard/src/lib/framework/layout-engine/location-wrapper.tsx

@@ -1,12 +1,11 @@
 import { CopyableText } from '@/vdb/components/shared/copyable-text.js';
-import { Button } from '@/vdb/components/ui/button.js';
 import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
 import { usePageBlock } from '@/vdb/hooks/use-page-block.js';
 import { usePage } from '@/vdb/hooks/use-page.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { cn } from '@/vdb/lib/utils.js';
-import { CodeXmlIcon } from 'lucide-react';
 import React, { useEffect, useState } from 'react';
+import { DevModeButton } from './dev-mode-button.js';
 
 // Singleton state for hover tracking
 let globalHoveredId: string | null = null;
@@ -87,13 +86,7 @@ export function LocationWrapper({ children, identifier }: Readonly<LocationWrapp
                 >
                     <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
                         <PopoverTrigger asChild>
-                            <Button
-                                variant="secondary"
-                                size="icon"
-                                className="h-8 w-8 rounded-full bg-dev-mode/10 hover:bg-dev-mode/20 border border-dev-mode/20 shadow-sm"
-                            >
-                                <CodeXmlIcon className="text-dev-mode w-4 h-4" />
-                            </Button>
+                            <DevModeButton />
                         </PopoverTrigger>
                         <PopoverContent className="w-48 p-3">
                             <div className="space-y-2">