Improve explorer SSR, hydration, compaction, and smoke coverage.
Defer heavy getServerSideProps on home, operator, addresses, and wallet to cut TTFB; centralize locale-safe formatters with client-only relative times; add compact ops UX, bridge/operator relay pagination, and Playwright route/scroll smoke in deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
|
||||
import { formatInteger, formatTimestamp } from '@/utils/format'
|
||||
import { Explain, useUiMode } from './UiModeContext'
|
||||
|
||||
function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' {
|
||||
@@ -58,9 +59,14 @@ export default function ActivityContextPanel({
|
||||
const { mode } = useUiMode()
|
||||
const tone = resolveTone(context.state)
|
||||
const dualTimelineLabel =
|
||||
context.latest_block_timestamp && context.latest_transaction_timestamp
|
||||
? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx`
|
||||
: 'Dual timeline unavailable'
|
||||
context.latest_block_timestamp && context.latest_transaction_timestamp ? (
|
||||
<>
|
||||
<ClientRelativeTime value={context.latest_block_timestamp} suffix=" head" /> ·{' '}
|
||||
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" latest tx" />
|
||||
</>
|
||||
) : (
|
||||
'Dual timeline unavailable'
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="border border-sky-200 bg-sky-50/60 dark:border-sky-900/40 dark:bg-sky-950/20" title={title}>
|
||||
@@ -86,11 +92,11 @@ export default function ActivityContextPanel({
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '}
|
||||
was <ClientRelativeTime value={context.latest_block_timestamp} suffix=" ago" />. Latest visible transaction was{' '}
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
{formatRelativeAge(context.latest_transaction_timestamp)}.{' '}
|
||||
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.{' '}
|
||||
{mode === 'guided'
|
||||
? 'Use the block gap and last non-empty block above to tell low activity apart from an indexing issue.'
|
||||
: dualTimelineLabel}
|
||||
@@ -104,11 +110,11 @@ export default function ActivityContextPanel({
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
was {formatRelativeAge(context.latest_block_timestamp)}. Latest visible transaction was{' '}
|
||||
was <ClientRelativeTime value={context.latest_block_timestamp} suffix=" ago" />. Latest visible transaction was{' '}
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
|
||||
</span>{' '}
|
||||
{formatRelativeAge(context.latest_transaction_timestamp)}.
|
||||
<ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
@@ -135,7 +141,7 @@ export default function ActivityContextPanel({
|
||||
) : null}
|
||||
{context.block_gap_to_latest_transaction != null ? (
|
||||
<span>
|
||||
Block gap to latest visible transaction: {context.block_gap_to_latest_transaction.toLocaleString()}
|
||||
Block gap to latest visible transaction: {formatInteger(context.block_gap_to_latest_transaction)}
|
||||
</span>
|
||||
) : null}
|
||||
{context.latest_transaction_timestamp ? (
|
||||
|
||||
57
frontend/src/components/common/ClientRelativeTime.tsx
Normal file
57
frontend/src/components/common/ClientRelativeTime.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatRelativeAgeAt } from '@/utils/format'
|
||||
|
||||
const PLACEHOLDER = '…'
|
||||
|
||||
export default function ClientRelativeTime({
|
||||
value,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
fallback = 'Unknown',
|
||||
className,
|
||||
}: {
|
||||
value?: string | null
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
fallback?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [label, setLabel] = useState(PLACEHOLDER)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setLabel(fallback)
|
||||
return
|
||||
}
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setLabel(fallback)
|
||||
return
|
||||
}
|
||||
const update = () => setLabel(formatRelativeAgeAt(Date.now(), value))
|
||||
update()
|
||||
const id = window.setInterval(update, 30_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [value, fallback])
|
||||
|
||||
const content = label === PLACEHOLDER ? PLACEHOLDER : label
|
||||
if (className) {
|
||||
return (
|
||||
<span className={className} suppressHydrationWarning>
|
||||
{prefix}
|
||||
{content}
|
||||
{suffix}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{prefix}
|
||||
{content}
|
||||
{suffix}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/common/CompactMetricBar.tsx
Normal file
29
frontend/src/components/common/CompactMetricBar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
export type CompactMetric = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export default function CompactMetricBar({
|
||||
metrics,
|
||||
className = 'mb-4',
|
||||
}: {
|
||||
metrics: CompactMetric[]
|
||||
className?: string
|
||||
}) {
|
||||
if (metrics.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300 ${className}`}
|
||||
>
|
||||
{metrics.map((metric) => (
|
||||
<span key={metric.label}>
|
||||
<span className="text-gray-500 dark:text-gray-400">{metric.label}:</span>{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{metric.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/common/DisclosureSection.tsx
Normal file
70
frontend/src/components/common/DisclosureSection.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
|
||||
interface DisclosureSectionProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
defaultOpen?: boolean
|
||||
className?: string
|
||||
headingClassName?: string
|
||||
/** When true, content is always visible and the toggle is hidden (desktop layout). */
|
||||
forceOpen?: boolean
|
||||
/** When true, keep accordion behavior on all breakpoints (no md:auto-expand). */
|
||||
alwaysCollapsible?: boolean
|
||||
}
|
||||
|
||||
export default function DisclosureSection({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className = '',
|
||||
headingClassName = '',
|
||||
forceOpen = false,
|
||||
alwaysCollapsible = false,
|
||||
}: DisclosureSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen || forceOpen)
|
||||
const panelId = useId().replace(/:/g, '')
|
||||
const isOpen = forceOpen || open
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
{forceOpen ? (
|
||||
<div className={`mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 ${headingClassName}`}>
|
||||
{title}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50/80 px-3 py-2.5 text-left text-sm font-semibold uppercase tracking-wide text-gray-700 transition hover:border-primary-300 dark:border-gray-800 dark:bg-gray-900/40 dark:text-gray-200 dark:hover:border-primary-700 ${alwaysCollapsible ? '' : 'md:hidden'} ${headingClassName}`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span aria-hidden="true" className="text-base text-gray-500 dark:text-gray-400">
|
||||
{isOpen ? '−' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
className={
|
||||
forceOpen
|
||||
? ''
|
||||
: alwaysCollapsible
|
||||
? `${isOpen ? 'block' : 'hidden'} mt-3`
|
||||
: `${isOpen ? 'block' : 'hidden'} mt-3 md:mt-0 md:block`
|
||||
}
|
||||
>
|
||||
{!forceOpen && !alwaysCollapsible ? (
|
||||
<div className="mb-3 hidden text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 md:block">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import type { ExplorerStats } from '@/services/api/stats'
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
|
||||
export default function ExplorerFreshnessDisclosure({
|
||||
context,
|
||||
stats,
|
||||
bridgeStatus,
|
||||
scopeLabel,
|
||||
activityTitle = 'Recency',
|
||||
title = 'Index freshness',
|
||||
className = 'mb-4',
|
||||
children,
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
stats?: ExplorerStats | null
|
||||
bridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||
scopeLabel: string
|
||||
activityTitle?: string
|
||||
title?: string
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<DisclosureSection title={title} defaultOpen={false} alwaysCollapsible className={className}>
|
||||
<ActivityContextPanel compact context={context} title={activityTitle} />
|
||||
<FreshnessTrustNote
|
||||
className="mt-2"
|
||||
context={context}
|
||||
stats={stats}
|
||||
bridgeStatus={bridgeStatus}
|
||||
scopeLabel={scopeLabel}
|
||||
/>
|
||||
{children}
|
||||
</DisclosureSection>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import DisclosureSection from '@/components/common/DisclosureSection'
|
||||
import FooterLinkGroups from '@/components/common/FooterLinkGroups'
|
||||
import FooterPublicApiLinks from '@/components/common/FooterPublicApiLinks'
|
||||
|
||||
const footerLinkClass =
|
||||
@@ -8,96 +10,53 @@ export default function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="mt-auto border-t border-gray-200 dark:border-gray-700 bg-white/90 dark:bg-gray-900/90 backdrop-blur">
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white sm:text-lg">
|
||||
DBIS Explorer
|
||||
</div>
|
||||
<p className="max-w-xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Built on Blockscout for the DBIS Chain 138 explorer surface.
|
||||
Explorer data is powered by Blockscout, Chain 138 RPC, and the companion MetaMask Snap.
|
||||
</p>
|
||||
<p className="max-w-xl text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Primary public explorer access is served at <code>explorer.d-bis.org</code>.
|
||||
<code> blockscout.defi-oracle.io</code> is the Blockscout companion domain for the same Chain 138 explorer surface.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
© {year} DBIS. All rights reserved.
|
||||
<footer className="mt-auto border-t border-gray-200 bg-white/90 backdrop-blur dark:border-gray-700 dark:bg-gray-900/90">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 shrink-0 lg:max-w-xs">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">DBIS Explorer</div>
|
||||
<p className="mt-1 text-xs leading-5 text-gray-600 dark:text-gray-400">
|
||||
Chain 138 explorer on Blockscout. Primary domain: <code>explorer.d-bis.org</code>.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-500">© {year} DBIS</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Resources
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link className={footerLinkClass} href="/search">Search</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/docs">Documentation</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/blocks">Blocks</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/transactions">Transactions</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/tokens">Tokens</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/addresses">Addresses</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/watchlist">Watchlist</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/access">Account access</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/wallet">Wallet tools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operations">Operations hub</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/bridge">Bridge</Link></li>
|
||||
<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>
|
||||
<li><Link className={footerLinkClass} href="/weth">WETH</Link></li>
|
||||
<li><a className={footerLinkClass} href="/privacy.html">Privacy Policy</a></li>
|
||||
<li><a className={footerLinkClass} href="/terms.html">Terms of Service</a></li>
|
||||
<li><a className={footerLinkClass} href="/acknowledgments.html">Acknowledgments</a></li>
|
||||
</ul>
|
||||
<div className="hidden min-w-0 flex-1 lg:block">
|
||||
<FooterLinkGroups />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosureSection
|
||||
title="Site navigation"
|
||||
alwaysCollapsible
|
||||
className="mt-3 lg:hidden"
|
||||
>
|
||||
<FooterLinkGroups />
|
||||
</DisclosureSection>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Public APIs
|
||||
</div>
|
||||
<FooterPublicApiLinks />
|
||||
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Read-only JSON endpoints on the public explorer domain. No API key required.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<DisclosureSection title="Public APIs" alwaysCollapsible className="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<FooterPublicApiLinks compact />
|
||||
</DisclosureSection>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-900/40 md:border-0 md:bg-transparent md:p-0">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Contact
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<DisclosureSection title="Contact" alwaysCollapsible className="rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<div className="space-y-1.5 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
Support:{' '}
|
||||
<a className={footerLinkClass} href="mailto:support@d-bis.org">
|
||||
support@d-bis.org
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Snap site:{' '}
|
||||
<Link className={footerLinkClass} href="/topology">
|
||||
Chain 138 map
|
||||
</Link>
|
||||
{' · '}
|
||||
<a className={footerLinkClass} href="/snap/" target="_blank" rel="noopener noreferrer">
|
||||
/snap/ on the current explorer domain
|
||||
Snap site
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Command center:{' '}
|
||||
<Link className={footerLinkClass} href="/topology">
|
||||
Chain 138 visual map
|
||||
</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
|
||||
can be sent to the support mailbox above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DisclosureSection>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
46
frontend/src/components/common/FooterLinkGroups.tsx
Normal file
46
frontend/src/components/common/FooterLinkGroups.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { footerNavGroups, type FooterNavLink } from '@/data/footerNav'
|
||||
|
||||
const footerLinkClass =
|
||||
'text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors'
|
||||
|
||||
function FooterNavLinkItem({ link }: { link: FooterNavLink }) {
|
||||
if (link.external) {
|
||||
return (
|
||||
<li>
|
||||
<a className={footerLinkClass} href={link.href}>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link className={footerLinkClass} href={link.href}>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FooterLinkGroups() {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-4">
|
||||
{footerNavGroups.map((group) => (
|
||||
<div key={group.id}>
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{group.title}
|
||||
</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{group.links.map((link) => (
|
||||
<FooterNavLinkItem key={`${group.id}-${link.href}`} link={link} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ function absoluteApiUrl(href: string): string {
|
||||
return `${window.location.origin}${href.startsWith('/') ? href : `/${href}`}`
|
||||
}
|
||||
|
||||
export default function FooterPublicApiLinks() {
|
||||
export default function FooterPublicApiLinks({ compact = false }: { compact?: boolean }) {
|
||||
const [copiedHref, setCopiedHref] = useState<string | null>(null)
|
||||
|
||||
const copyUrl = async (href: string) => {
|
||||
@@ -27,15 +27,23 @@ export default function FooterPublicApiLinks() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-3 text-sm">
|
||||
<ul className={compact ? 'space-y-1.5 text-sm' : 'space-y-3 text-sm'}>
|
||||
{explorerPublicApiLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a className={footerLinkClass} href={link.href} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
className={footerLinkClass}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={compact ? link.description : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
|
||||
{!compact ? (
|
||||
<p className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-500">{link.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
68
frontend/src/components/common/FreshnessDetail.tsx
Normal file
68
frontend/src/components/common/FreshnessDetail.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ChainActivityContext } from '@/utils/activityContext'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import { formatInteger } from '@/utils/format'
|
||||
|
||||
export default function FreshnessDetail({
|
||||
context,
|
||||
diagnosticExplanation,
|
||||
activityState,
|
||||
}: {
|
||||
context: ChainActivityContext
|
||||
diagnosticExplanation?: string | null
|
||||
activityState?: string | null
|
||||
}) {
|
||||
if (diagnosticExplanation) {
|
||||
return <>{diagnosticExplanation}</>
|
||||
}
|
||||
|
||||
if (activityState === 'quiet_chain') {
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${formatInteger(context.last_non_empty_block_number)}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${formatInteger(context.block_gap_to_latest_transaction)} blocks`
|
||||
: 'unknown'
|
||||
return (
|
||||
<>
|
||||
Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible
|
||||
transaction: {blockGap}. Last non-empty block: {latestNonEmptyBlock}.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return <>Use chain-head visibility and the last non-empty block as the current trust anchors.</>
|
||||
}
|
||||
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${formatInteger(context.last_non_empty_block_number)}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${formatInteger(context.block_gap_to_latest_transaction)} blocks`
|
||||
: null
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return (
|
||||
<>
|
||||
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />. Block
|
||||
gap: {blockGap || 'unknown'}. Last non-empty block: {latestNonEmptyBlock}.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return (
|
||||
<>
|
||||
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />.
|
||||
Recent indexed activity remains close to the tip.
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Latest visible transaction: <ClientRelativeTime value={context.latest_transaction_timestamp} suffix=" ago" />. Recent
|
||||
head blocks may be quiet even while the chain remains current.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveFreshnessSourceLabel,
|
||||
summarizeFreshnessConfidence,
|
||||
} from '@/utils/explorerFreshness'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
import FreshnessDetail from './FreshnessDetail'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
|
||||
function buildSummary(context: ChainActivityContext, activityState?: string | null) {
|
||||
@@ -32,44 +32,6 @@ function buildSummary(context: ChainActivityContext, activityState?: string | nu
|
||||
return 'Freshness context is based on the latest visible public explorer evidence.'
|
||||
}
|
||||
|
||||
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null, activityState?: string | null) {
|
||||
if (diagnosticExplanation) {
|
||||
return diagnosticExplanation
|
||||
}
|
||||
|
||||
if (activityState === 'quiet_chain') {
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
|
||||
: 'unknown'
|
||||
return `Quiet-chain signal: head blocks may be empty while the chain remains current. Block gap to latest visible transaction: ${blockGap}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.transaction_visibility_unavailable) {
|
||||
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
|
||||
}
|
||||
|
||||
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
|
||||
const latestNonEmptyBlock =
|
||||
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
|
||||
const blockGap =
|
||||
context.block_gap_to_latest_transaction != null
|
||||
? `${context.block_gap_to_latest_transaction.toLocaleString()} blocks`
|
||||
: null
|
||||
|
||||
if (context.head_is_idle) {
|
||||
return `Latest visible transaction: ${latestTxAge}. Block gap: ${blockGap || 'unknown'}. Last non-empty block: ${latestNonEmptyBlock}.`
|
||||
}
|
||||
|
||||
if (context.state === 'active') {
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent indexed activity remains close to the tip.`
|
||||
}
|
||||
|
||||
return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.`
|
||||
}
|
||||
|
||||
function normalizeSentence(value?: string | null): string {
|
||||
if (!value) return ''
|
||||
return value.trim().replace(/[.\s]+$/, '')
|
||||
@@ -120,8 +82,12 @@ export default function FreshnessTrustNote({
|
||||
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context, activityState)}</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{normalizeSentence(buildDetail(context, diagnosticExplanation, activityState))}.{' '}
|
||||
{scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
|
||||
<FreshnessDetail
|
||||
context={context}
|
||||
diagnosticExplanation={diagnosticExplanation}
|
||||
activityState={activityState}
|
||||
/>
|
||||
. {scopeLabel ? `${normalizeSentence(scopeLabel)}. ` : ''}
|
||||
{normalizeSentence(sourceLabel)}.
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
45
frontend/src/components/common/ListFilterBar.tsx
Normal file
45
frontend/src/components/common/ListFilterBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { formatInteger } from '@/utils/format'
|
||||
|
||||
interface ListFilterBarProps {
|
||||
query: string
|
||||
onQueryChange: (value: string) => void
|
||||
placeholder?: string
|
||||
resultCount?: number
|
||||
totalCount?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ListFilterBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
placeholder = 'Filter…',
|
||||
resultCount,
|
||||
totalCount,
|
||||
className = '',
|
||||
}: ListFilterBarProps) {
|
||||
const showCounts = typeof resultCount === 'number' && typeof totalCount === 'number'
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between ${className}`}>
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className="sr-only">{placeholder}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</label>
|
||||
{showCounts ? (
|
||||
<div className="shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
||||
{resultCount === totalCount
|
||||
? `${formatInteger(totalCount)} items`
|
||||
: `${formatInteger(resultCount)} of ${formatInteger(totalCount)}`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import { formatTimestamp } from '@/utils/format'
|
||||
|
||||
function formatSource(source?: string | null): string {
|
||||
switch (source) {
|
||||
@@ -26,14 +27,33 @@ export default function MarketEvidenceNote({
|
||||
method?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const freshness = lastUpdated ? `${formatRelativeAge(lastUpdated)} (${formatTimestamp(lastUpdated)})` : 'timestamp unavailable'
|
||||
const text = compact
|
||||
? `Updated ${freshness} · ${formatSource(source)}`
|
||||
: `Source: ${formatSource(source)}. Updated: ${freshness}. Method: ${method}`
|
||||
|
||||
return (
|
||||
<p className={`${compact ? 'mt-1' : 'mt-3'} text-xs leading-5 text-gray-500 dark:text-gray-400`}>
|
||||
{text}
|
||||
{compact ? (
|
||||
<>
|
||||
Updated{' '}
|
||||
{lastUpdated ? (
|
||||
<>
|
||||
<ClientRelativeTime value={lastUpdated} suffix={` (${formatTimestamp(lastUpdated)})`} />
|
||||
</>
|
||||
) : (
|
||||
'timestamp unavailable'
|
||||
)}{' '}
|
||||
· {formatSource(source)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Source: {formatSource(source)}. Updated:{' '}
|
||||
{lastUpdated ? (
|
||||
<>
|
||||
<ClientRelativeTime value={lastUpdated} suffix={` (${formatTimestamp(lastUpdated)})`} />
|
||||
</>
|
||||
) : (
|
||||
'timestamp unavailable'
|
||||
)}
|
||||
. Method: {method}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import type { MissionControlMode } from '@/services/api/missionControl'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
live_homepage_stream_not_attached: 'Live homepage stream is not attached; relay posture uses snapshot polling.',
|
||||
@@ -57,7 +57,9 @@ export default function MissionDeliveryModePanel({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={`mode ${mode.kind}`} tone={modeTone(mode.kind)} />
|
||||
{mode.updated_at ? (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">Updated {formatRelativeAge(mode.updated_at)}</span>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Updated <ClientRelativeTime value={mode.updated_at} suffix=" ago" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{mode.reason ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { accessApi, institutionalTierLabels, type WalletAccessSession } from '@/
|
||||
import BrandLockup from './BrandLockup'
|
||||
import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
import { formatTimestamp } from '@/utils/format'
|
||||
|
||||
type MenuItem = {
|
||||
href?: string
|
||||
@@ -430,7 +431,7 @@ function AccountButton({
|
||||
const sessionSummary = getSessionSummary(walletSession)
|
||||
const tierLabel = getAccessTier(walletSession)
|
||||
const expiresLabel = walletSession.expiresAt
|
||||
? new Date(walletSession.expiresAt).toLocaleString()
|
||||
? formatTimestamp(walletSession.expiresAt)
|
||||
: 'Session expiry unavailable'
|
||||
|
||||
return (
|
||||
@@ -488,6 +489,7 @@ export default function Navbar() {
|
||||
pathname.startsWith('/addresses')
|
||||
const isDataActive =
|
||||
pathname.startsWith('/tokens') ||
|
||||
pathname.startsWith('/wallet') ||
|
||||
pathname.startsWith('/analytics') ||
|
||||
pathname.startsWith('/pools') ||
|
||||
pathname.startsWith('/protocols') ||
|
||||
@@ -592,6 +594,7 @@ export default function Navbar() {
|
||||
const dataItems: MenuItem[] = useMemo(
|
||||
() => [
|
||||
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
|
||||
{ href: '/wallet', label: 'Wallet tools', description: 'Add Chain 138 to MetaMask and import tokens via EIP-747.' },
|
||||
{ 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.' },
|
||||
|
||||
@@ -10,39 +10,53 @@ export default function PageIntro({
|
||||
title,
|
||||
description,
|
||||
actions = [],
|
||||
compact = false,
|
||||
}: {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description: string
|
||||
actions?: PageIntroAction[]
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<section
|
||||
className={
|
||||
compact
|
||||
? 'mb-4 border-b border-gray-200 pb-3 dark:border-gray-800'
|
||||
: 'mb-5 border-b border-gray-200 pb-5 dark:border-gray-800 sm:mb-6 sm:pb-6'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
{eyebrow ? (
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-700 dark:text-primary-300">
|
||||
{eyebrow}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-gray-950 dark:text-white sm:text-3xl">{title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
<h1 className={`font-semibold tracking-normal text-gray-950 dark:text-white ${compact ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl'}`}>
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={`mt-1.5 max-w-3xl text-sm text-gray-600 dark:text-gray-400 ${
|
||||
compact ? 'line-clamp-2 leading-5' : 'leading-6'
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
{actions.map((action) => (
|
||||
<Link
|
||||
key={`${action.href}-${action.label}`}
|
||||
href={action.href}
|
||||
className="rounded-lg border border-gray-300 bg-white 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:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{actions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
{actions.map((action) => (
|
||||
<Link
|
||||
key={`${action.href}-${action.label}`}
|
||||
href={action.href}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:text-primary-300"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useId } from 'react'
|
||||
import { useCallback, useId, type ReactNode } from 'react'
|
||||
import { formatInteger } from '@/utils/format'
|
||||
|
||||
export interface SectionTab<T extends string> {
|
||||
id: T
|
||||
@@ -28,6 +29,33 @@ export function sectionTabPanelProps<T extends string>(
|
||||
}
|
||||
}
|
||||
|
||||
interface TabPanelProps<T extends string> {
|
||||
idPrefix: string
|
||||
tabId: T
|
||||
activeTab: T
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders tab content only while active to keep pages short and avoid hidden-panel scroll. */
|
||||
export function TabPanel<T extends string>({
|
||||
idPrefix,
|
||||
tabId,
|
||||
activeTab,
|
||||
children,
|
||||
className = '',
|
||||
}: TabPanelProps<T>) {
|
||||
if (activeTab !== tabId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...sectionTabPanelProps(idPrefix, tabId, activeTab)} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SectionTabs<T extends string>({
|
||||
tabs,
|
||||
activeTab,
|
||||
@@ -78,7 +106,7 @@ export default function SectionTabs<T extends string>({
|
||||
|
||||
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}`}
|
||||
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-2 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -88,6 +116,10 @@ export default function SectionTabs<T extends string>({
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
const accessibleLabel =
|
||||
typeof tab.count === 'number'
|
||||
? `${tab.label} (${formatInteger(tab.count)})`
|
||||
: tab.label
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -97,6 +129,7 @@ export default function SectionTabs<T extends string>({
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
|
||||
aria-label={accessibleLabel}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
@@ -107,8 +140,11 @@ export default function SectionTabs<T extends string>({
|
||||
>
|
||||
{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
|
||||
aria-hidden="true"
|
||||
className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}
|
||||
>
|
||||
{formatInteger(tab.count)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import { useUiMode } from './UiModeContext'
|
||||
import ClientRelativeTime from '@/components/common/ClientRelativeTime'
|
||||
import type { MissionControlSubsystemStatus } from '@/services/api/missionControl'
|
||||
import { formatRelativeAge } from '@/utils/format'
|
||||
|
||||
function subsystemLabel(key: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
@@ -107,7 +107,13 @@ export default function SubsystemPosturePanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{subsystem.updated_at ? `Updated ${formatRelativeAge(subsystem.updated_at)}` : 'Update time unavailable'}
|
||||
{subsystem.updated_at ? (
|
||||
<>
|
||||
Updated <ClientRelativeTime value={subsystem.updated_at} suffix=" ago" />
|
||||
</>
|
||||
) : (
|
||||
'Update time unavailable'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{compactMeta(subsystem)}
|
||||
|
||||
Reference in New Issue
Block a user