- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
384 lines
19 KiB
TypeScript
384 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import { useEffect, useId, useRef, useState } from 'react'
|
|
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
|
|
|
const navItemBase =
|
|
'rounded-xl px-3 py-2 text-[15px] font-medium transition-all duration-150'
|
|
const navLink =
|
|
`${navItemBase} text-gray-700 dark:text-gray-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:bg-gray-700/70 dark:hover:text-primary-300`
|
|
const navLinkActive =
|
|
`${navItemBase} bg-primary-50 text-primary-700 shadow-sm ring-1 ring-primary-100 dark:bg-primary-500/10 dark:text-primary-300 dark:ring-primary-500/20`
|
|
|
|
function NavDropdown({
|
|
label,
|
|
icon,
|
|
active,
|
|
children,
|
|
}: {
|
|
label: string
|
|
icon: React.ReactNode
|
|
active?: boolean
|
|
children: React.ReactNode
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
|
const menuId = useId()
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
|
const target = event.target as Node | null
|
|
if (!target || !wrapperRef.current?.contains(target)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handlePointerDown)
|
|
document.addEventListener('touchstart', handlePointerDown)
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handlePointerDown)
|
|
document.removeEventListener('touchstart', handlePointerDown)
|
|
document.removeEventListener('keydown', handleKeyDown)
|
|
}
|
|
}, [open])
|
|
|
|
return (
|
|
<div
|
|
ref={wrapperRef}
|
|
className="relative"
|
|
onMouseEnter={() => setOpen(true)}
|
|
onMouseLeave={() => setOpen(false)}
|
|
onBlurCapture={(event) => {
|
|
const nextTarget = event.relatedTarget as Node | null
|
|
if (nextTarget && wrapperRef.current?.contains(nextTarget)) {
|
|
return
|
|
}
|
|
setOpen(false)
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={`flex items-center gap-1.5 ${active && !open ? navLinkActive : navLink}`}
|
|
onClick={() => setOpen((value) => !value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setOpen(true)
|
|
}
|
|
}}
|
|
aria-expanded={open}
|
|
aria-haspopup="true"
|
|
aria-controls={menuId}
|
|
>
|
|
{icon}
|
|
<span>{label}</span>
|
|
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{open && (
|
|
<ul
|
|
id={menuId}
|
|
className="absolute left-0 top-full z-50 mt-1 min-w-[220px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
|
role="menu"
|
|
>
|
|
{children}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DropdownItem({
|
|
href,
|
|
icon,
|
|
children,
|
|
external,
|
|
}: {
|
|
href: string
|
|
icon?: React.ReactNode
|
|
children: React.ReactNode
|
|
external?: boolean
|
|
}) {
|
|
const className =
|
|
'flex items-center gap-2 px-4 py-2.5 text-gray-700 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-primary-400'
|
|
if (external) {
|
|
return (
|
|
<li role="none">
|
|
<a href={href} className={className} role="menuitem" target="_blank" rel="noopener noreferrer">
|
|
{icon}
|
|
<span>{children}</span>
|
|
</a>
|
|
</li>
|
|
)
|
|
}
|
|
return (
|
|
<li role="none">
|
|
<Link href={href} className={className} role="menuitem">
|
|
{icon}
|
|
<span>{children}</span>
|
|
</Link>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
export default function Navbar() {
|
|
const router = useRouter()
|
|
const pathname = usePathname() ?? ''
|
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
|
const [exploreOpen, setExploreOpen] = useState(false)
|
|
const [dataOpen, setDataOpen] = useState(false)
|
|
const [operationsOpen, setOperationsOpen] = useState(false)
|
|
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
|
const [connectingWallet, setConnectingWallet] = useState(false)
|
|
|
|
const isExploreActive =
|
|
pathname === '/' ||
|
|
pathname.startsWith('/blocks') ||
|
|
pathname.startsWith('/transactions') ||
|
|
pathname.startsWith('/addresses')
|
|
const isDataActive =
|
|
pathname.startsWith('/tokens') ||
|
|
pathname.startsWith('/pools') ||
|
|
pathname.startsWith('/analytics') ||
|
|
pathname.startsWith('/watchlist')
|
|
const isOperationsActive =
|
|
pathname.startsWith('/bridge') ||
|
|
pathname.startsWith('/routes') ||
|
|
pathname.startsWith('/liquidity') ||
|
|
pathname.startsWith('/operations') ||
|
|
pathname.startsWith('/operator') ||
|
|
pathname.startsWith('/system') ||
|
|
pathname.startsWith('/weth')
|
|
const isDocsActive = pathname.startsWith('/docs')
|
|
const isAccessActive = pathname.startsWith('/access')
|
|
|
|
useEffect(() => {
|
|
const syncWalletSession = () => {
|
|
setWalletSession(accessApi.getStoredWalletSession())
|
|
}
|
|
|
|
syncWalletSession()
|
|
window.addEventListener('storage', syncWalletSession)
|
|
window.addEventListener('explorer-access-session-changed', syncWalletSession)
|
|
return () => {
|
|
window.removeEventListener('storage', syncWalletSession)
|
|
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
|
|
}
|
|
}, [])
|
|
|
|
const handleAccessClick = async () => {
|
|
if (walletSession) {
|
|
router.push('/access')
|
|
setMobileMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
setConnectingWallet(true)
|
|
await accessApi.connectWalletSession()
|
|
router.push('/access')
|
|
setMobileMenuOpen(false)
|
|
} catch (error) {
|
|
console.error('Wallet connect failed', error)
|
|
router.push('/access')
|
|
setMobileMenuOpen(false)
|
|
} finally {
|
|
setConnectingWallet(false)
|
|
}
|
|
}
|
|
|
|
const toggleMobileMenu = () => {
|
|
setMobileMenuOpen((open) => {
|
|
const nextOpen = !open
|
|
if (!nextOpen) {
|
|
setExploreOpen(false)
|
|
setDataOpen(false)
|
|
setOperationsOpen(false)
|
|
}
|
|
return nextOpen
|
|
})
|
|
}
|
|
|
|
return (
|
|
<nav className="border-b border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
|
<div className="container mx-auto px-4">
|
|
<div className="flex h-14 items-center justify-between sm:h-16">
|
|
<div className="flex min-w-0 items-center gap-3 md:gap-6">
|
|
<Link
|
|
href="/"
|
|
className="group inline-flex max-w-[calc(100vw-5rem)] flex-col rounded-xl px-2 py-1.5 text-base font-bold text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-gray-700/70 sm:max-w-none sm:px-2.5 sm:py-1.5 sm:text-[1.05rem]"
|
|
onClick={() => setMobileMenuOpen(false)}
|
|
aria-label="Go to explorer home"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary-600 text-white shadow-sm transition-transform group-hover:-translate-y-0.5 dark:bg-primary-500 sm:h-8 sm:w-8">
|
|
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
<path d="M12 2.5 3.5 6.5v11l8.5 4 8.5-4v-11L12 2.5Zm0 2.24 6.44 3.03L12 10.8 5.56 7.77 12 4.74Zm-7 4.63L11 13.1v6.07L5 16.4V9.37Zm9 9.8v-6.07l6-2.92v6.03l-6 2.96Z" />
|
|
</svg>
|
|
</span>
|
|
<span className="min-w-0 truncate">
|
|
<span className="sm:hidden">SolaceScan</span>
|
|
<span className="hidden sm:inline">SolaceScan</span>
|
|
</span>
|
|
</span>
|
|
<span className="mt-0.5 hidden text-[0.78rem] font-normal text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200 sm:block">
|
|
Chain 138 Explorer by DBIS
|
|
</span>
|
|
</Link>
|
|
<div className="hidden items-center gap-1.5 md:flex">
|
|
<NavDropdown
|
|
label="Explore"
|
|
active={isExploreActive}
|
|
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0h.5a2.5 2.5 0 002.5-2.5V3.935M12 12a2 2 0 104 0 2 2 0 00-4 0z" /></svg>}
|
|
>
|
|
<DropdownItem href="/" icon={<span className="text-gray-400">⌂</span>}>Home</DropdownItem>
|
|
<DropdownItem href="/blocks" icon={<span className="text-gray-400">▣</span>}>Blocks</DropdownItem>
|
|
<DropdownItem href="/transactions" icon={<span className="text-gray-400">⇄</span>}>Transactions</DropdownItem>
|
|
<DropdownItem href="/addresses" icon={<span className="text-gray-400">⌗</span>}>Addresses</DropdownItem>
|
|
</NavDropdown>
|
|
<Link
|
|
href="/search"
|
|
className={`hidden md:inline-flex items-center ${pathname.startsWith('/search') ? navLinkActive : navLink}`}
|
|
>
|
|
Search
|
|
</Link>
|
|
<NavDropdown
|
|
label="Data"
|
|
active={isDataActive}
|
|
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7h16M4 12h16M4 17h10" /></svg>}
|
|
>
|
|
<DropdownItem href="/tokens">Tokens</DropdownItem>
|
|
<DropdownItem href="/analytics">Analytics</DropdownItem>
|
|
<DropdownItem href="/pools">Pools</DropdownItem>
|
|
<DropdownItem href="/watchlist">Watchlist</DropdownItem>
|
|
</NavDropdown>
|
|
<Link
|
|
href="/docs"
|
|
className={`hidden md:inline-flex items-center ${isDocsActive ? navLinkActive : navLink}`}
|
|
>
|
|
Docs
|
|
</Link>
|
|
<NavDropdown
|
|
label="Operations"
|
|
active={isOperationsActive}
|
|
icon={<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>}
|
|
>
|
|
<DropdownItem href="/operations">Operations Hub</DropdownItem>
|
|
<DropdownItem href="/bridge">Bridge</DropdownItem>
|
|
<DropdownItem href="/routes">Routes</DropdownItem>
|
|
<DropdownItem href="/liquidity">Liquidity</DropdownItem>
|
|
<DropdownItem href="/system">System</DropdownItem>
|
|
<DropdownItem href="/operator">Operator</DropdownItem>
|
|
<DropdownItem href="/weth">WETH</DropdownItem>
|
|
<DropdownItem href="/chain138-command-center.html" external>Command Center</DropdownItem>
|
|
</NavDropdown>
|
|
<Link
|
|
href="/wallet"
|
|
className={`hidden md:inline-flex items-center ${pathname.startsWith('/wallet') ? navLinkActive : navLink}`}
|
|
>
|
|
Wallet
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleAccessClick()}
|
|
className={`hidden md:inline-flex items-center ${isAccessActive ? navLinkActive : navLink}`}
|
|
>
|
|
{connectingWallet ? 'Connecting…' : walletSession ? 'Access' : 'Connect Wallet'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center md:hidden">
|
|
<button
|
|
type="button"
|
|
className="rounded-md p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
|
onClick={toggleMobileMenu}
|
|
aria-expanded={mobileMenuOpen}
|
|
aria-label="Toggle menu"
|
|
>
|
|
{mobileMenuOpen ? (
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
|
) : (
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{mobileMenuOpen && (
|
|
<div className="border-t border-gray-200 py-2 pb-3 dark:border-gray-700 md:hidden">
|
|
<div className="flex flex-col gap-1">
|
|
<Link href="/" className={pathname === '/' ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Home</Link>
|
|
<Link href="/search" className={pathname.startsWith('/search') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Search</Link>
|
|
<div className="relative">
|
|
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setExploreOpen((value) => !value)} aria-expanded={exploreOpen}>
|
|
<span>Explore</span>
|
|
<svg className={`h-4 w-4 transition-transform ${exploreOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
|
</button>
|
|
{exploreOpen && (
|
|
<ul className="mt-1 space-y-0.5 pl-4">
|
|
<li><Link href="/blocks" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Blocks</Link></li>
|
|
<li><Link href="/transactions" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Transactions</Link></li>
|
|
<li><Link href="/addresses" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Addresses</Link></li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setDataOpen((value) => !value)} aria-expanded={dataOpen}>
|
|
<span>Data</span>
|
|
<svg className={`h-4 w-4 transition-transform ${dataOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
|
</button>
|
|
{dataOpen && (
|
|
<ul className="mt-1 space-y-0.5 pl-4">
|
|
<li><Link href="/tokens" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Tokens</Link></li>
|
|
<li><Link href="/analytics" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Analytics</Link></li>
|
|
<li><Link href="/pools" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Pools</Link></li>
|
|
<li><Link href="/watchlist" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Watchlist</Link></li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
<Link href="/docs" className={isDocsActive ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Docs</Link>
|
|
<div className="relative">
|
|
<button type="button" className={`flex w-full items-center justify-between ${navLink}`} onClick={() => setOperationsOpen((value) => !value)} aria-expanded={operationsOpen}>
|
|
<span>Operations</span>
|
|
<svg className={`h-4 w-4 transition-transform ${operationsOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
|
</button>
|
|
{operationsOpen && (
|
|
<ul className="mt-1 space-y-0.5 pl-4">
|
|
<li><Link href="/operations" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operations Hub</Link></li>
|
|
<li><Link href="/bridge" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Bridge</Link></li>
|
|
<li><Link href="/routes" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Routes</Link></li>
|
|
<li><Link href="/liquidity" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Liquidity</Link></li>
|
|
<li><Link href="/system" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>System</Link></li>
|
|
<li><Link href="/operator" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>Operator</Link></li>
|
|
<li><Link href="/weth" className={`block rounded-md px-3 py-2 ${navLink}`} onClick={() => setMobileMenuOpen(false)}>WETH</Link></li>
|
|
<li><a href="/chain138-command-center.html" className={`block rounded-md px-3 py-2 ${navLink}`} target="_blank" rel="noopener noreferrer" onClick={() => setMobileMenuOpen(false)}>Command Center</a></li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
<Link href="/wallet" className={pathname.startsWith('/wallet') ? navLinkActive : navLink} onClick={() => setMobileMenuOpen(false)}>Wallet</Link>
|
|
<button
|
|
type="button"
|
|
className={`w-full text-left ${isAccessActive ? navLinkActive : navLink}`}
|
|
onClick={() => void handleAccessClick()}
|
|
>
|
|
{connectingWallet ? 'Connecting wallet…' : walletSession ? 'Access' : 'Connect wallet'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|