From d11924e63fee24a0e0b8492d3d66b30b5d18ee8d Mon Sep 17 00:00:00 2001 From: Anastasia Rodionova Date: Fri, 17 Jan 2025 20:09:49 +0800 Subject: [PATCH] Add sidebar --- apps/web/package.json | 2 + apps/web/src/app/data.ts | 11 +- .../app/decode/[chainID]/[hash]/loading.tsx | 14 +- apps/web/src/app/globals.css | 56 +- .../interpret/[chainID]/[hash]/loading.tsx | 16 +- apps/web/src/app/layout.tsx | 3 - apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/collapsible.tsx | 11 + apps/web/src/components/ui/examples.tsx | 58 +- apps/web/src/components/ui/input.tsx | 30 +- apps/web/src/components/ui/sheet.tsx | 109 +++ apps/web/src/components/ui/sidebar.tsx | 640 ++++++++++++++++++ apps/web/src/components/ui/skeleton.tsx | 7 + apps/web/src/components/ui/tooltip.tsx | 30 + apps/web/src/hooks/use-mobile.tsx | 19 + apps/web/tailwind.config.js | 130 ++-- pnpm-lock.yaml | 359 +++++++++- 17 files changed, 1357 insertions(+), 140 deletions(-) create mode 100644 apps/web/src/components/ui/collapsible.tsx create mode 100644 apps/web/src/components/ui/sheet.tsx create mode 100644 apps/web/src/components/ui/sidebar.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/components/ui/tooltip.tsx create mode 100644 apps/web/src/hooks/use-mobile.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 61daba57..c2ad634b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,8 @@ "@3loop/transaction-interpreter": "workspace:*", "@jitl/quickjs-singlefile-browser-release-sync": "^0.29.2", "@monaco-editor/react": "^4.6.0", + "@radix-ui/react-collapsible": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-icons": "^1.3.0", diff --git a/apps/web/src/app/data.ts b/apps/web/src/app/data.ts index 3089e6ba..ec0d17e0 100644 --- a/apps/web/src/app/data.ts +++ b/apps/web/src/app/data.ts @@ -179,12 +179,17 @@ export const DEFAULT_CHAIN_ID = 1 const generateNavItems = (transactions: any, path: string) => { return transactions.map((tx: any) => ({ - href: `/${path}/${tx.chainID}/${tx.hash}`, + url: `/${path}/${tx.chainID}/${tx.hash}`, title: `${tx.name}`, })) } -export const geSidebarNavItems = (path: string) => - Object.fromEntries(Object.entries(EXAMPLE_TXS).map(([key, value]) => [key, generateNavItems(value, path)])) +export const geSidebarNavItems = (path: string) => { + return Object.entries(EXAMPLE_TXS).map(([key, value]) => ({ + title: key, + url: `#`, + items: generateNavItems(value, path) as { url: string; title: string }[], + })) +} export const INTERPRETER_REPO = 'https://github.com/3loop/loop-decoder/tree/main/packages/transaction-interpreter' diff --git a/apps/web/src/app/decode/[chainID]/[hash]/loading.tsx b/apps/web/src/app/decode/[chainID]/[hash]/loading.tsx index 2ca62878..4c265895 100644 --- a/apps/web/src/app/decode/[chainID]/[hash]/loading.tsx +++ b/apps/web/src/app/decode/[chainID]/[hash]/loading.tsx @@ -1,9 +1,8 @@ import { Label } from '@/components/ui/label' -import { SidebarNav } from '@/components/ui/sidebar-nav' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { NetworkSelect } from '@/components/ui/network-select' -import { geSidebarNavItems } from '@/app/data' +import { ExampleTransactions } from '@/components/ui/examples' export default function Loading() { return ( @@ -32,16 +31,7 @@ export default function Loading() {
-
-

Example Transactions

- - {Object.entries(geSidebarNavItems('decode')).map(([heading, items]) => ( -
-

{heading}

- -
- ))} -
+
) diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6a757250..98883066 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,7 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; - + @layer base { :root { --background: 0 0% 100%; @@ -9,63 +9,81 @@ --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; - + --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; - + --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; - + --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; - + --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; - + --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; - + --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; - + --radius: 0.5rem; + + --sidebar-primary: 240 5.9% 10%; + + --sidebar-primary-foreground: 0 0% 98%; + + --sidebar-accent: 240 4.8% 95.9%; + + --sidebar-accent-foreground: 240 5.9% 10%; + + --sidebar-border: 220 13% 91%; + + --sidebar-ring: 217.2 91.2% 59.8%; } - + .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; - + --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; - + --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; - + --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; - + --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; - + --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; - + --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; - + --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - + --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } } - + @layer base { * { @apply border-border; @@ -73,4 +91,4 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +} diff --git a/apps/web/src/app/interpret/[chainID]/[hash]/loading.tsx b/apps/web/src/app/interpret/[chainID]/[hash]/loading.tsx index 0c620023..6ee8f1ad 100644 --- a/apps/web/src/app/interpret/[chainID]/[hash]/loading.tsx +++ b/apps/web/src/app/interpret/[chainID]/[hash]/loading.tsx @@ -1,10 +1,9 @@ import { Label } from '@/components/ui/label' -import { SidebarNav } from '@/components/ui/sidebar-nav' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { PlayIcon } from 'lucide-react' import { NetworkSelect } from '@/components/ui/network-select' -import { geSidebarNavItems } from '@/app/data' +import { ExampleTransactions } from '@/components/ui/examples' export default function Loading() { return ( @@ -49,17 +48,8 @@ export default function Loading() { -
-
-

Example Transactions

- - {Object.entries(geSidebarNavItems('interpret')).map(([heading, items]) => ( -
-

{heading}

- -
- ))} -
+
+
) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index a029a7e9..75f55b9f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,4 @@ import { Separator } from '@/components/ui/separator' -import { Terminal } from 'lucide-react' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import './globals.css' import type { Metadata } from 'next' import { Inter } from 'next/font/google' @@ -123,7 +121,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
-
{children} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index a6d66a30..f0d7a899 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( - 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..1bbaed5a --- /dev/null +++ b/apps/web/src/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/apps/web/src/components/ui/examples.tsx b/apps/web/src/components/ui/examples.tsx index a5c436b9..85eed4c6 100644 --- a/apps/web/src/components/ui/examples.tsx +++ b/apps/web/src/components/ui/examples.tsx @@ -1,17 +1,55 @@ import { geSidebarNavItems } from '@/app/data' -import { SidebarNav } from '@/components/ui/sidebar-nav' +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarProvider, +} from '@/components/ui/sidebar' + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' export function ExampleTransactions({ path }: { path: string }) { return ( -
-

Example Transactions

+ + + + + Example Transactions + + + {geSidebarNavItems(path).map(({ title, items }, index) => ( + + + {title} - {Object.entries(geSidebarNavItems(path)).map(([heading, items]) => ( -
-

{heading}

- -
- ))} -
+ {items + ? items.map((item) => ( + + + + + {item.title} + + + + + )) + : null} + + + ))} + + + + + + ) } diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index deb6d0c4..b508d5ab 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -2,21 +2,21 @@ import * as React from 'react' import { cn } from '@/lib/utils' -export interface InputProps extends React.InputHTMLAttributes {} - -const Input = React.forwardRef(({ className, type, ...props }, ref) => { - return ( - - ) -}) +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + }, +) Input.displayName = 'Input' export { Input } diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx new file mode 100644 index 00000000..72266ee9 --- /dev/null +++ b/apps/web/src/components/ui/sheet.tsx @@ -0,0 +1,109 @@ +'use client' + +import * as React from 'react' +import * as SheetPrimitive from '@radix-ui/react-dialog' +import { cva, type VariantProps } from 'class-variance-authority' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>( + ({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + + ), +) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..57407980 --- /dev/null +++ b/apps/web/src/components/ui/sidebar.tsx @@ -0,0 +1,640 @@ +//@ts-nocheck +'use client' + +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { 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 } 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 SidebarContext = { + 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 ( + + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +}) +Sidebar.displayName = 'Sidebar' + +const SidebarTrigger = React.forwardRef, React.ComponentProps>( + ({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) + }, +) +SidebarTrigger.displayName = 'SidebarTrigger' + +const SidebarRail = React.forwardRef>( + ({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +