mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
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]>
215 lines
8.3 KiB
TypeScript
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);
|