diff --git a/src/App.tsx b/src/App.tsx index bcee967..56ed4bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,38 +1,35 @@ import { VaultTxModal } from "polkahub" import { Navigate, Route, Routes } from "react-router-dom" +import { ConnectionState } from "./components/ConnectionState" import { Accounts } from "./pages/Accounts/Accounts" +import { AppShell } from "./pages/AppShell" import { Constants } from "./pages/Constants" import { Explorer } from "./pages/Explorer" import { Extrinsics } from "./pages/Extrinsics" -import { Header } from "./pages/Header" import { Metadata } from "./pages/Metadata" import { RpcCalls } from "./pages/RpcCalls" import { RuntimeCalls } from "./pages/RuntimeCalls" import { Storage } from "./pages/Storage" import { Transactions } from "./pages/Transactions" import { ViewFns } from "./pages/ViewFns" -import { ConnectionState } from "./components/ConnectionState" export default function App() { return ( -
-
-
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
+
+ + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/src/components/GlobalCommandPalette.tsx b/src/components/GlobalCommandPalette.tsx new file mode 100644 index 0000000..e7e11cd --- /dev/null +++ b/src/components/GlobalCommandPalette.tsx @@ -0,0 +1,235 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { useNavigate } from "@/hashParams" +import { GitGraph } from "lucide-react" +import { ComponentType, FC, useEffect, useMemo, useRef, useState } from "react" +import { twMerge } from "tailwind-merge" + +type CommandPaletteIcon = ComponentType<{ size?: number; className?: string }> + +export type NavigationItem = { + path: string + label: string + icon: CommandPaletteIcon +} + +type CommandAction = { + id: string + path: string + label: string + description: string + icon: CommandPaletteIcon +} + +export const GlobalCommandPalette: FC<{ + navigationItems: NavigationItem[] +}> = ({ navigationItems }) => { + const navigate = useNavigate() + const [value, setValue] = useState("") + const [open, setOpen] = useState(false) + const inputRef = useRef(null) + const commandRef = useRef(null) + const shortcutLabel = getCommandShortcutLabel() + const { primaryActions, sectionActions } = useMemo( + () => getCommandActions(value, navigationItems), + [navigationItems, value], + ) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault() + setOpen(true) + inputRef.current?.focus() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, []) + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + if (!commandRef.current?.contains(event.target as Node)) { + setOpen(false) + } + } + + document.addEventListener("pointerdown", handlePointerDown) + return () => document.removeEventListener("pointerdown", handlePointerDown) + }, []) + + const runAction = (action: CommandAction) => { + navigate(action.path) + setValue("") + setOpen(false) + } + + return ( +
+ { + if (event.key === "Escape") { + setOpen(false) + inputRef.current?.blur() + } + }} + > + { + setValue(nextValue) + setOpen(true) + }} + onFocus={() => setOpen(true)} + placeholder="Block hash, block number, or command" + spellCheck={false} + autoComplete="off" + /> + + {shortcutLabel} + + {open ? ( +
+ + No matching command. + {primaryActions.length ? ( + + {primaryActions.map((action) => ( + + ))} + + ) : null} + + {sectionActions.map((action) => ( + + ))} + + +
+ ) : null} +
+
+ ) +} + +const CommandActionItem: FC<{ + action: CommandAction + onSelect: (action: CommandAction) => void +}> = ({ action, onSelect }) => { + const Icon = action.icon + + return ( + onSelect(action)}> + +
+
{action.label}
+
+ {action.description} +
+
+
+ ) +} + +const getCommandActions = ( + value: string, + navigationItems: NavigationItem[], +) => { + const query = value.trim() + const primaryActions: CommandAction[] = [] + + if (query.startsWith("/")) { + primaryActions.push({ + id: `route:${query}`, + path: query, + label: `Open ${query}`, + description: "Navigate to route", + icon: GitGraph, + }) + } else if (/^0x[0-9a-f]+$/i.test(query)) { + primaryActions.push({ + id: `block-hash:${query}`, + path: `/explorer/${query}`, + label: "Open block hash", + description: query, + icon: GitGraph, + }) + } else if (/^\d+$/.test(query)) { + primaryActions.push({ + id: `block-number:${query}`, + path: `/explorer/${query}`, + label: `Open block ${Number(query).toLocaleString()}`, + description: "Navigate by block number", + icon: GitGraph, + }) + } + + return { + primaryActions, + sectionActions: getSectionActions(query, navigationItems), + } +} + +const getSectionActions = ( + query: string, + navigationItems: NavigationItem[], +): CommandAction[] => { + const normalizedQuery = normalizeLabel(query) + const items = !normalizedQuery + ? navigationItems + : [...navigationItems] + .filter(({ label }) => normalizeLabel(label).includes(normalizedQuery)) + .sort((a, b) => { + const aLabel = normalizeLabel(a.label) + const bLabel = normalizeLabel(b.label) + if (aLabel === normalizedQuery) return -1 + if (bLabel === normalizedQuery) return 1 + if ( + aLabel.startsWith(normalizedQuery) !== + bLabel.startsWith(normalizedQuery) + ) { + return aLabel.startsWith(normalizedQuery) ? -1 : 1 + } + return a.label.localeCompare(b.label) + }) + + return items.map((item) => ({ + id: `section:${item.path}`, + path: item.path, + label: item.label, + description: "Open section", + icon: item.icon, + })) +} + +const normalizeLabel = (value: string) => + value.toLowerCase().replace(/[\s_-]/g, "") + +const getCommandShortcutLabel = () => + typeof navigator !== "undefined" && + /Mac|iPhone|iPad|iPod/.test(navigator.platform) + ? "⌘ K" + : "Ctrl K" diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 2760f67..f09b851 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -20,6 +20,8 @@ import chopsticksLogoDark from "./icons/chopsticks_dark.svg" import chopsticksLogoLight from "./icons/chopsticks_light.svg" import enumSvg from "./icons/enum.svg" import focusSvg from "./icons/focus.svg" +import githubLightSvg from "./icons/github_light.svg" +import githubDarkSvg from "./icons/github_dark.svg" import switchBinarySvg from "./icons/switch_binary.svg" type CustomIconProps = Omit & { size?: number } @@ -62,6 +64,7 @@ export const Enum = customIcon(enumSvg) export const BinaryEdit = customIcon(binarySvg) export const SwitchBinary = customIcon(switchBinarySvg) export const Chopsticks = themeIcon(chopsticksLogoLight, chopsticksLogoDark) +export const GithubIcon = themeIcon(githubLightSvg, githubDarkSvg) export const Spinner = (props: LucideProps) => ( + + + + + + + + + diff --git a/src/components/icons/github_light.svg b/src/components/icons/github_light.svg new file mode 100755 index 0000000..4f46bf3 --- /dev/null +++ b/src/components/icons/github_light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/ui/search-input.tsx b/src/components/ui/search-input.tsx deleted file mode 100644 index bafbe96..0000000 --- a/src/components/ui/search-input.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Search } from "lucide-react" -import { cn } from "@/lib/utils" -import { forwardRef } from "react" - -export const SearchInput = forwardRef< - HTMLInputElement, - Omit, "type"> ->(({ className, ...props }, ref) => ( -
- - -
-)) diff --git a/src/pages/AppShell.tsx b/src/pages/AppShell.tsx new file mode 100644 index 0000000..09680e7 --- /dev/null +++ b/src/pages/AppShell.tsx @@ -0,0 +1,247 @@ +import { + GlobalCommandPalette, + type NavigationItem, +} from "@/components/GlobalCommandPalette" +import SliderToggle from "@/components/Toggle" +import { GithubIcon } from "@/components/Icons" +import { Button } from "@/components/ui/button" +import { Sheet, SheetContent } from "@/components/ui/sheet" +import { Link } from "@/hashParams" +import { changeTheme, useTheme } from "@/ThemeProvider" +import { + BookOpenText, + Cable, + DatabaseSearch, + GitGraph, + Menu, + MoonStar, + Send, + ServerCog, + SquareEqual, + SquareFunction, + UserRound, +} from "lucide-react" +import { FC, PropsWithChildren, useState } from "react" +import { useLocation } from "react-router-dom" +import { twMerge } from "tailwind-merge" +import { NetworkSwitcher } from "./Network/Network" + +const navigationGroups: Array<{ + label: string + items: NavigationItem[] +}> = [ + { + label: "Dev Tools", + items: [ + { + path: "/explorer", + label: "Explorer", + // Alts GitFork, GitBranch + icon: GitGraph, + }, + { + path: "/storage", + label: "Storage", + icon: DatabaseSearch, + }, + { + path: "/extrinsics", + label: "Extrinsics", + icon: Send, + }, + { + path: "/runtimeCalls", + label: "Runtime Calls", + // Alt SquareTerminal + icon: ServerCog, + }, + { + path: "/viewFns", + label: "View Functions", + icon: SquareFunction, + }, + { + path: "/constants", + label: "Constants", + // Alt Braces + icon: SquareEqual, + }, + { + path: "/metadata", + label: "Metadata", + // Alt Boxes + icon: BookOpenText, + }, + { + path: "/rpcCalls", + label: "RPC Calls", + // Alt RadioTower + icon: Cable, + }, + ], + }, + { + label: "Utilities", + items: [ + { + path: "/accounts", + label: "Accounts", + // Alt Wallet + icon: UserRound, + }, + ], + }, +] + +const navigationItems = navigationGroups.flatMap((group) => group.items) + +export const AppShell: FC = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = useState(false) + + return ( +
+ + + + setSidebarOpen(false)} mobile /> + + +
+ setSidebarOpen(true)} /> +
+
+ {children} +
+
+
+
+ ) +} + +const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({ + mobile, + onNavigate, +}) => ( +
+
+ papi logo + papi logo +
+
+ papi{" "} + console +
+
beta
+
+
+ {mobile ? ( +
+ +
+ ) : null} + + +
+) + +const TopBar: FC<{ onOpenSidebar: () => void }> = ({ onOpenSidebar }) => { + return ( +
+ +
+
+ +
+ +
+
+ ) +} + +const SidebarLink: FC<{ item: NavigationItem; onClick?: () => void }> = ({ + item, + onClick, +}) => { + const location = useLocation() + const active = isNavigationItemActive(location.pathname, item.path) + const Icon = item.icon + + return ( + + + {item.label} + + ) +} + +const isNavigationItemActive = (pathname: string, path: string) => + pathname === path || pathname.startsWith(`${path}/`) + +const ThemeToggle = () => { + const theme = useTheme() + + return ( + + ) +} diff --git a/src/pages/Explorer/BlockTable.tsx b/src/pages/Explorer/BlockTable.tsx index f3e5d40..2b37698 100644 --- a/src/pages/Explorer/BlockTable.tsx +++ b/src/pages/Explorer/BlockTable.tsx @@ -1,12 +1,9 @@ import { CopyText } from "@/components/Copy" import { Popover } from "@/components/Popover" -import { Button } from "@/components/ui/button" -import { SearchInput } from "@/components/ui/search-input" -import { Link, useNavigate } from "@/hashParams" +import { Link } from "@/hashParams" import { BlockInfo, blocksByHeight$, finalized$ } from "@/state/block.state" import { client$ } from "@/state/chains/chain.state" import { state, useStateObservable } from "@react-rxjs/core" -import { Search } from "lucide-react" import { FC } from "react" import { combineLatest, debounceTime, map, switchMap } from "rxjs" import { twMerge } from "tailwind-merge" @@ -87,36 +84,6 @@ const blockTable$ = state( [], ) -export const BlockInput: FC = () => { - const navigate = useNavigate() - return ( -
{ - const blockTarget = new FormData(e.currentTarget).get( - "blockTarget", - ) as string - const isHex = blockTarget?.match(/^0[xX][0-9a-fA-F]+$/) - if ( - (isHex && blockTarget.length === 66) || - (!isHex && !Number.isNaN(Number(blockTarget))) - ) { - navigate(blockTarget) - } - e.preventDefault() - e.stopPropagation() - }} - > -
- -
- -
- ) -} - export const BlockTable = () => { const rows = useStateObservable(blockTable$) const finalized = useStateObservable(finalized$) @@ -133,7 +100,7 @@ export const BlockTable = () => { return ( - }>Recent Blocks + Recent Blocks {rows.map((row, i) => ( > = ({ - children, - search, -}) => ( +export const Title: FC = ({ children }) => (

{children} - {search}

) diff --git a/src/pages/Header.tsx b/src/pages/Header.tsx deleted file mode 100644 index f851163..0000000 --- a/src/pages/Header.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Button } from "@/components/ui/button" -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" -import { Link } from "@/hashParams" -import { Menu } from "lucide-react" -import { FC, PropsWithChildren, useState } from "react" -import { useLocation } from "react-router-dom" -import { twMerge } from "tailwind-merge" -import { NetworkSwitcher } from "./Network/Network" -import SliderToggle from "@/components/Toggle" -import { changeTheme, useTheme } from "@/ThemeProvider" - -const navigationItems = [ - { path: "/explorer", label: "Explorer", important: true }, - { path: "/storage", label: "Storage", important: true }, - { path: "/extrinsics", label: "Extrinsics", important: true }, - { path: "/constants", label: "Constants", important: false }, - { path: "/runtimeCalls", label: "Runtime Calls", important: true }, - { path: "/accounts", label: "Accounts", important: false }, -] - -export const Header = () => { - const [open, setIsOpen] = useState(false) - - return ( -
-
-
- papi-logo - papi-logo -

- papi console -

-
- (beta) -
-
- -
- - - - - - - - - setIsOpen(false)}> - View Functions - - setIsOpen(false)}> - Metadata - - setIsOpen(false)}> - RPC Calls - -
- -
- - - -
-
- ) -} - -const NavLink: FC< - PropsWithChildren<{ to: string; onClick?: () => void; className?: string }> -> = ({ to, children, className, onClick }) => { - const location = useLocation() - const active = location.pathname.startsWith(to) - - return ( - - {children} - - ) -} - -const ThemeToggle = () => { - const theme = useTheme() - - return ( - - ) -} diff --git a/src/pages/Network/Network.tsx b/src/pages/Network/Network.tsx index 59891a1..8875fae 100644 --- a/src/pages/Network/Network.tsx +++ b/src/pages/Network/Network.tsx @@ -36,11 +36,7 @@ import { Check, ChevronDown } from "lucide-react" import { FC, useState } from "react" import { twMerge } from "tailwind-merge" -export function NetworkSwitcher({ - forSmallScreen, -}: { - forSmallScreen?: boolean -}) { +export function NetworkSwitcher({ className }: { className?: string }) { const [open, setOpen] = useState(false) const selectedChain = useStateObservable(selectedChain$) @@ -91,8 +87,8 @@ export function NetworkSwitcher({