Files
explore/ui/snippets/topBar/TopBar.tsx
T
zeekayandHanzo Dev a3caebadf2 search: the icon sat on the placeholder, and validators counted one L1 of four
The magnifying glass was drawn over the leading "S" of the placeholder. The
cause is not the search bar: @luxfi/ui's InputGroup measures its adornments
correctly and then throws the measurement away, because the clone is guarded on
`displayName === 'FieldInput'` — a component that does not exist in the package
— and the props it would have set (`ps`/`pe`) are Chakra style props that the
Tailwind/gui Input spreads onto the DOM and ignores. Both halves are leftovers
of the Chakra→Tailwind port, so EVERY InputGroup in the app is unpadded; the
header is just the one you look at every day. Patched at the primitive
(patches/@[email protected]) rather than hand-tuning padding at one call
site: the offset is the measured width of the actual adornment, so it stays
right when the adornment changes, and FilterInput / revoke's SearchInput / the
showcase are fixed by the same change. Measured at 1440x900: the icon overlapped
the text by 24px and the kbd chip by 52px; both are now 0.

The measurement also moves to a layout effect and stops blanking value and
placeholder on first render. That blanking was inert while the guard swallowed
the clone; with the clone live it flips the field from controlled to
uncontrolled on mount, which React warns about and which can eat a keystroke.

ClearButton spread `className` over its own visibility classes, so a caller
passing one clobbered `invisible` — the header showed a clear button on an empty
field. Merged instead, at the component.

Placeholder is now "Search or ask anything", and Cmd/Ctrl-K opens an assistant.
Nothing in this repo, @luxfi/ui or @hanzo/gui ships a command palette or chat
(no cmdk, no palette, one dialog primitive), so this is the smallest thing that
works: the existing Dialog, one global listener, and a server-side proxy that
holds the credential. The key is deliberately not NEXT_PUBLIC_, so a deployment
without one gets a dialog that says exactly that instead of failing silently.
Off unless NEXT_PUBLIC_AI_ASSISTANT_ENABLED is set — Ctrl+K is the browser's
address-bar shortcut and claiming it to show an error would be worse than not
claiming it.

Validators read 5 on a network running 20. useCurrentValidators asks ONE
P-Chain, the one this deployment points at, but Lux, Hanzo, Zoo and Pars are
each sovereign L1s with their own P-Chain and their own validator set — measured
2026-08-06, five each, with four disjoint NodeID sets. The registry gains the
node origin per chain; absence of it is the signal that a chain has no publicly
reachable node (SPC and Osage are in-cluster only), and those are reported
`unavailable` rather than counted as zero. Stake is deliberately NOT summed:
each chain bonds its own currency.

The proxy resolves `?chain=` through the registry and never from the request, so
it cannot be pointed at an arbitrary origin. The new envs are registered with
the envs-validator and documented in ENVS.md — the schema rejects unknown
NEXT_PUBLIC_* variables and runs at container start, so an undeclared one stops
the pod from booting.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 19:17:01 -07:00

215 lines
8.3 KiB
TypeScript

import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from '@luxfi/ui/menu';
import { useRouter } from 'next/router';
import React from 'react';
import { route } from 'nextjs-routes';
import { getCurrentChain, isChainSelectorEnabled, isNetworkSelectorEnabled } from 'configs/app/chainRegistry';
import { cn } from 'lib/utils/cn';
import { Link } from 'toolkit/next/link';
import { CONTENT_MAX_WIDTH } from 'ui/shared/layout/utils';
import SearchBarAssistant from 'ui/snippets/searchBar/SearchBarAssistant';
import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
import ChainSwitcher from './ChainSwitcher';
import NetworkSelector from './NetworkSelector';
// -- Nav link --
interface NavLinkProps {
readonly text: string;
readonly href: string;
readonly isActive: boolean;
}
const NavLinkItem = ({ text, href, isActive }: NavLinkProps) => (
<Link
href={ href }
className={ cn(
'px-2 py-1 text-xs rounded-sm no-underline whitespace-nowrap transition-all duration-150',
'hover:text-[var(--color-text-primary)] hover:bg-[var(--color-blackAlpha-50)] dark:hover:bg-[var(--color-whiteAlpha-50)]',
isActive ? 'font-semibold text-[var(--color-text-primary)]' : 'font-medium text-[var(--color-text-secondary)]',
) }
variant="plain"
>
{ text }
</Link>
);
// -- Blockchain sub-menu items --
const BLOCKCHAIN_ITEMS = [
{ text: 'Blocks', pathname: '/blocks' as const },
{ text: 'Transactions', pathname: '/txs' as const },
{ text: 'Tokens', pathname: '/tokens' as const },
{ text: 'Accounts', pathname: '/accounts' as const },
{ text: 'Verified Contracts', pathname: '/verified-contracts' as const },
] as const;
// -- Main component --
const TopBar = () => {
const router = useRouter();
const pathname = router.pathname;
const chain = getCurrentChain();
const isHome = pathname === '/';
const isBlockchainActive = pathname === '/blocks' || pathname.startsWith('/block/') ||
pathname === '/txs' || pathname.startsWith('/tx/') ||
pathname === '/tokens' || pathname.startsWith('/token/') ||
pathname === '/accounts' || pathname.startsWith('/address/') ||
pathname === '/verified-contracts';
return (
<div
className={ cn(
// Desktop-only: HeaderMobile (block lg:hidden) is the sole header below
// lg. Without this, both render < lg → a double header (two sign-ins).
'hidden lg:block sticky top-0 left-0 w-full max-w-[100vw] z-sticky',
'border-b border-[var(--color-border-divider)]',
'backdrop-blur-[16px] bg-[rgba(255,255,255,0.97)] dark:bg-[rgba(16,17,18,0.97)]',
) }
>
<div
className="flex py-2 px-4 lg:px-6 mx-auto items-center gap-2"
style={{ maxWidth: `${ CONTENT_MAX_WIDTH }px` }}
>
{ /* -- Logo + Brand + Chain/Network selectors (grouped) -- */ }
<div className="flex items-center gap-2 shrink-0">
<a
href={ route({ pathname: '/' as const }) }
className="group flex items-center gap-[6px] shrink-0 no-underline hover:no-underline transition-all duration-300"
aria-label={ `${ chain.branding.brandName } home` }
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={ chain.branding.logoViewBox }
className={ cn(
'shrink-0 transition-all duration-300 ease-out',
'group-hover:scale-110 group-hover:drop-shadow-[0_0_8px_rgba(74,158,255,0.4)]',
isHome ? 'w-[26px] h-[26px]' : 'w-5 h-5',
) }
dangerouslySetInnerHTML={{ __html: chain.branding.logoContent }}
/>
<div className={ cn(
'flex flex-col leading-none overflow-hidden transition-all duration-300 ease-out',
isHome ? 'max-w-[200px] opacity-100' : 'max-w-[80px] opacity-100',
) }>
<span className={ cn(
'font-bold whitespace-nowrap text-[var(--color-text-primary)] transition-all duration-300',
isHome ? 'text-[14px] tracking-[-0.02em]' : 'text-[13px] tracking-[-0.03em]',
) }>
{ isHome ? chain.branding.brandName : chain.branding.brandName.split(' ')[0] }
</span>
<span className={ cn(
'text-[10px] tracking-[0.03em] whitespace-nowrap text-[var(--color-text-secondary)] transition-all duration-300',
isHome ? 'max-h-[14px] opacity-100 mt-[2px]' : 'max-h-0 opacity-0 mt-0',
) }>
{ chain.branding.productName }
</span>
</div>
</a>
{ /* Network & Chain selectors — adjacent to logo */ }
{ isNetworkSelectorEnabled() && <NetworkSelector/> }
{ isChainSelectorEnabled() && <ChainSwitcher/> }
</div>
{ /* -- Divider -- */ }
<div className="h-4 w-px bg-[var(--color-border-divider)] shrink-0 mx-1"/>
{ /* -- Navigation -- */ }
<nav className="hidden lg:flex items-center gap-1 shrink-0 min-w-0">
{ /* Blockchain dropdown */ }
<MenuRoot>
<MenuTrigger asChild>
<button
className={ cn(
'px-2 py-1 text-xs rounded-sm cursor-pointer transition-all duration-150',
'flex items-center gap-0.5 bg-transparent border-none',
'hover:text-[var(--color-text-primary)] hover:bg-[var(--color-blackAlpha-50)] dark:hover:bg-[var(--color-whiteAlpha-50)]',
isBlockchainActive ? 'font-semibold text-[var(--color-text-primary)]' : 'font-medium text-[var(--color-text-secondary)]',
) }
>
Blockchain
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-[14px] h-[14px]"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
</MenuTrigger>
<MenuContent minW="180px">
{ BLOCKCHAIN_ITEMS.map((item) => (
<MenuItem
key={ item.pathname }
value={ item.pathname }
asChild
>
<Link
href={ route({ pathname: item.pathname }) }
className="no-underline hover:no-underline"
variant="plain"
>
{ item.text }
</Link>
</MenuItem>
)) }
</MenuContent>
</MenuRoot>
<NavLinkItem
text="Chains"
href={ route({ pathname: '/chains' as const }) }
isActive={ pathname === '/chains' || pathname.startsWith('/chain/') }
/>
<NavLinkItem
text="Validators"
href={ route({ pathname: '/validators' as const }) }
isActive={ pathname === '/validators' || pathname.startsWith('/validators/') }
/>
<NavLinkItem
text="Stats"
href={ route({ pathname: '/stats' as const }) }
isActive={ pathname.startsWith('/stats') }
/>
<NavLinkItem
text="Bridge"
href={ route({ pathname: '/bridge' as const }) }
isActive={ pathname === '/bridge' }
/>
<NavLinkItem
text="DEX"
href={ route({ pathname: '/dex' as const }) }
isActive={ pathname === '/dex' }
/>
</nav>
{ /* -- Search bar (center, flexible) -- */ }
<div className="flex-1 mx-3 hidden lg:block max-w-[480px] min-w-[180px]">
<SearchBar isHeroBanner={ false }/>
</div>
<SearchBarAssistant/>
{ /* -- Spacer -- */ }
<div className="flex-1 block lg:hidden"/>
{ /* -- User profile / Sign in -- */ }
<div className="shrink-0 ml-auto">
<UserProfileDesktop buttonSize="sm"/>
</div>
</div>
</div>
);
};
export default React.memo(TopBar);