+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
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)} />
+
+
+
+ )
+}
+
+const SidebarContent: FC<{ mobile?: boolean; onNavigate?: () => void }> = ({
+ mobile,
+ onNavigate,
+}) => (
+
+
+

+

+
+
+ 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 (
-
- )
-}
-
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 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({