feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
import ExplorerDocumentHead from './ExplorerDocumentHead'
|
||||
import { UiModeProvider } from './UiModeContext'
|
||||
import { PostureGlossaryProvider } from './PostureGlossaryProvider'
|
||||
|
||||
@@ -9,6 +10,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UiModeProvider>
|
||||
<PostureGlossaryProvider>
|
||||
<ExplorerDocumentHead />
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
|
||||
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal file
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const BASE_TITLE = 'DBIS Explorer'
|
||||
|
||||
function resolveDocumentTitle(pathname: string, query: Record<string, string | string[] | undefined>): string {
|
||||
if (pathname === '/') return `${BASE_TITLE} — Chain 138`
|
||||
if (pathname === '/search') return `Search — ${BASE_TITLE}`
|
||||
if (pathname === '/wallet') return `Wallet Tools — ${BASE_TITLE}`
|
||||
if (pathname === '/protocols') return `Official Protocol Contracts — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/protocols/')) {
|
||||
const id = typeof query.id === 'string' ? query.id : pathname.split('/').pop() || 'Protocol'
|
||||
return `${id} — Protocols — ${BASE_TITLE}`
|
||||
}
|
||||
if (pathname === '/pools') return `Pool Registry — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/pools/')) return `Pool Detail — ${BASE_TITLE}`
|
||||
if (pathname === '/liquidity') return `Liquidity — ${BASE_TITLE}`
|
||||
if (pathname === '/operations') return `Operations — ${BASE_TITLE}`
|
||||
if (pathname === '/docs') return `Documentation — ${BASE_TITLE}`
|
||||
if (pathname === '/tokens') return `Tokens — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/tokens/')) return `Token — ${BASE_TITLE}`
|
||||
if (pathname === '/addresses') return `Addresses — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/addresses/')) return `Address — ${BASE_TITLE}`
|
||||
if (pathname === '/blocks') return `Blocks — ${BASE_TITLE}`
|
||||
if (pathname === '/transactions') return `Transactions — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/transactions/')) return `Transaction — ${BASE_TITLE}`
|
||||
if (pathname === '/bridge') return `Bridge — ${BASE_TITLE}`
|
||||
if (pathname === '/routes') return `Routes — ${BASE_TITLE}`
|
||||
if (pathname === '/analytics') return `Analytics — ${BASE_TITLE}`
|
||||
if (pathname === '/operator') return `Operator — ${BASE_TITLE}`
|
||||
if (pathname === '/system') return `System — ${BASE_TITLE}`
|
||||
if (pathname === '/weth') return `WETH — ${BASE_TITLE}`
|
||||
if (pathname === '/watchlist') return `Watchlist — ${BASE_TITLE}`
|
||||
if (pathname === '/access') return `Account Access — ${BASE_TITLE}`
|
||||
|
||||
const segment = pathname.split('/').filter(Boolean)[0]
|
||||
if (segment) {
|
||||
const label = segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
return `${label} — ${BASE_TITLE}`
|
||||
}
|
||||
return BASE_TITLE
|
||||
}
|
||||
|
||||
export default function ExplorerDocumentHead() {
|
||||
const router = useRouter()
|
||||
const pathname = router.pathname || '/'
|
||||
const title = resolveDocumentTitle(pathname, router.query)
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export default function Footer() {
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/protocols">Protocols</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/system">System</Link></li>
|
||||
@@ -87,9 +88,9 @@ export default function Footer() {
|
||||
</p>
|
||||
<p>
|
||||
Command center:{' '}
|
||||
<a className={footerLinkClass} href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">
|
||||
<Link className={footerLinkClass} href="/topology">
|
||||
Chain 138 visual map
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Questions about the explorer, chain metadata, route discovery, or liquidity access
|
||||
|
||||
@@ -490,6 +490,7 @@ export default function Navbar() {
|
||||
pathname.startsWith('/tokens') ||
|
||||
pathname.startsWith('/analytics') ||
|
||||
pathname.startsWith('/pools') ||
|
||||
pathname.startsWith('/protocols') ||
|
||||
pathname.startsWith('/watchlist')
|
||||
const isOperationsActive =
|
||||
pathname.startsWith('/bridge') ||
|
||||
@@ -593,6 +594,7 @@ export default function Navbar() {
|
||||
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
|
||||
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
|
||||
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
|
||||
{ href: '/protocols', label: 'Protocols', description: 'Review official upstream protocol contracts and production guardrails on Chain 138.' },
|
||||
{ href: '/watchlist', label: 'Watchlist', description: 'Jump into tracked addresses and saved explorer entities.' },
|
||||
],
|
||||
[],
|
||||
@@ -603,10 +605,11 @@ export default function Navbar() {
|
||||
{ href: '/bridge', label: 'Bridge', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
|
||||
{ href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' },
|
||||
{ href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' },
|
||||
{ href: '/protocols', label: 'Protocols', description: 'Official DODO, UniV2/V3, CCIP, and integration contracts on Chain 138.' },
|
||||
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
|
||||
{ href: '/operator', label: 'Operator', description: 'Open planner, route, and relay shortcuts in one public page.' },
|
||||
{ href: '/weth', label: 'WETH', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
|
||||
{ href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true },
|
||||
{ href: '/topology', label: 'Command Center', description: 'Open the visual topology and architecture map.' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -1,60 +1,97 @@
|
||||
import { buildPaginationItems } from '@/utils/pagination'
|
||||
|
||||
interface PaginationControlsProps {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPageChange: (page: number) => void
|
||||
label?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
/** Known total pages for bounded pagination. Omit for cursor-style list pagination. */
|
||||
pageCount?: number
|
||||
/** Used when pageCount is omitted. */
|
||||
hasNextPage?: boolean
|
||||
}
|
||||
|
||||
const buttonClassName =
|
||||
'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
|
||||
const activeButtonClassName = 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
|
||||
export default function PaginationControls({
|
||||
page,
|
||||
pageCount,
|
||||
hasNextPage = false,
|
||||
onPageChange,
|
||||
label = 'Rows',
|
||||
className = '',
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
}: PaginationControlsProps) {
|
||||
if (pageCount <= 1) return null
|
||||
const isBounded = typeof pageCount === 'number'
|
||||
const boundedPageCount = pageCount ?? 1
|
||||
|
||||
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
|
||||
if (isBounded && boundedPageCount <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isBounded && page <= 1 && !hasNextPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const paginationItems = isBounded ? buildPaginationItems(page, boundedPageCount) : []
|
||||
const canGoPrevious = page > 1
|
||||
const canGoNext = isBounded ? page < boundedPageCount : hasNextPage
|
||||
const navLabel = ariaLabel || `${label} pagination`
|
||||
const statusText = isBounded ? `${label}: page ${page} of ${boundedPageCount}` : `${label}: page ${page}`
|
||||
|
||||
return (
|
||||
<div className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{label}: page {page} of {pageCount}
|
||||
</div>
|
||||
<nav
|
||||
className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}
|
||||
aria-label={navLabel}
|
||||
>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{statusText}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
disabled={disabled || !canGoPrevious}
|
||||
className={buttonClassName}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{pages.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
onClick={() => onPageChange(candidate)}
|
||||
aria-current={candidate === page ? 'page' : undefined}
|
||||
className={
|
||||
candidate === page
|
||||
? 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{candidate}
|
||||
</button>
|
||||
))}
|
||||
{isBounded ? (
|
||||
<ul className="flex flex-wrap items-center gap-2">
|
||||
{paginationItems.map((item) =>
|
||||
item.type === 'ellipsis' ? (
|
||||
<li key={item.key} className="px-1 text-sm text-gray-500 dark:text-gray-400" aria-hidden="true">
|
||||
…
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.page}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(item.page)}
|
||||
disabled={disabled}
|
||||
aria-current={item.page === page ? 'page' : undefined}
|
||||
className={item.page === page ? activeButtonClassName : buttonClassName}
|
||||
>
|
||||
{item.page}
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
|
||||
disabled={page >= pageCount}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
onClick={() => onPageChange(isBounded ? Math.min(boundedPageCount, page + 1) : page + 1)}
|
||||
disabled={disabled || !canGoNext}
|
||||
className={buttonClassName}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useId } from 'react'
|
||||
|
||||
export interface SectionTab<T extends string> {
|
||||
id: T
|
||||
label: string
|
||||
@@ -9,6 +11,21 @@ interface SectionTabsProps<T extends string> {
|
||||
activeTab: T
|
||||
onChange: (tab: T) => void
|
||||
className?: string
|
||||
idPrefix?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function sectionTabPanelProps<T extends string>(
|
||||
idPrefix: string,
|
||||
tabId: T,
|
||||
activeTab: T,
|
||||
) {
|
||||
return {
|
||||
id: `${idPrefix}-panel-${tabId}`,
|
||||
role: 'tabpanel' as const,
|
||||
'aria-labelledby': `${idPrefix}-tab-${tabId}`,
|
||||
hidden: activeTab !== tabId,
|
||||
}
|
||||
}
|
||||
|
||||
export default function SectionTabs<T extends string>({
|
||||
@@ -16,29 +33,87 @@ export default function SectionTabs<T extends string>({
|
||||
activeTab,
|
||||
onChange,
|
||||
className = '',
|
||||
idPrefix,
|
||||
ariaLabel = 'Sections',
|
||||
}: SectionTabsProps<T>) {
|
||||
const generatedId = useId().replace(/:/g, '')
|
||||
const tabListPrefix = idPrefix || generatedId
|
||||
|
||||
const focusTab = useCallback(
|
||||
(tabId: T) => {
|
||||
const element = document.getElementById(`${tabListPrefix}-tab-${tabId}`)
|
||||
element?.focus()
|
||||
},
|
||||
[tabListPrefix],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab)
|
||||
if (currentIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let nextIndex = currentIndex
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const nextTab = tabs[nextIndex]
|
||||
onChange(nextTab.id)
|
||||
focusTab(nextTab.id)
|
||||
},
|
||||
[activeTab, focusTab, onChange, tabs],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
activeTab === tab.id
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={activeTab === tab.id ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex gap-2 overflow-x-auto"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`${tabListPrefix}-tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
isActive
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user