ui: real URLs for every view
The hash router was the right size for a chain and a window. It stopped being once a block, a miner and — shortly — an account each became a page worth sending someone: a fragment never reaches a server, so those URLs cannot carry a title, be crawled, or be resolved by anything but the page itself. So paths, via react-router, which was already a dependency and unused. Both vhosts already answer any path with `index.html`, so nothing outside the client changed. `lib/routes` holds the grammar in one place and `fromLegacyHash` rewrites the old form on load, so every `#/…` link already published still lands where it meant to. Segments are named — `/quantus/block/13160`, not `/quantus/13160` — rather than told apart by shape. Shape works until two kinds of thing can look alike, and an SS58 address is arbitrary base58 with no rule keeping it clear of a window name forever. Six characters buys the whole class of problem away before accounts arrive. Everything navigable is an anchor now, chain chips and the window control included, which is what "real URLs" has to mean in practice: middle-click opens another chain's 24h view in a tab, and the back button walks the panels. The miner panel had no address at all before this — opening one changed the page and not the URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
@@ -134,11 +134,12 @@ minutes.
|
||||
|
||||
## A block has two addresses, and only one of them is stable
|
||||
|
||||
`#/quantus/1069799` is a *position*. Because `block` is keyed on (chain, height)
|
||||
and a reorg overwrites, a link to a height quietly comes to mean a different
|
||||
block the moment the chain forks there. So opening one resolves it and rewrites
|
||||
the address to `#/quantus/0x…`, which names one block for good — including after
|
||||
it has lost, which is the only way an orphan is linkable at all.
|
||||
`/quantus/block/1069799` is a *position*. Because `block` is keyed on
|
||||
(chain, height) and a reorg overwrites, a link to a height quietly comes to mean
|
||||
a different block the moment the chain forks there. So opening one resolves it
|
||||
and rewrites the address to `/quantus/block/0x…`, which names one block for good
|
||||
— including after it has lost, which is the only way an orphan is linkable at
|
||||
all.
|
||||
|
||||
The page is assembled from both sources because neither is sufficient. The node
|
||||
holds the block and forgets it: `blocks-pruning` defaults to
|
||||
@@ -149,6 +150,31 @@ the tip — and what it has since forgotten, the difficulty recorded while the
|
||||
state still existed. Anything neither can answer says so in words rather than
|
||||
going blank, because a gap on a block page reads as a zero.
|
||||
|
||||
## Every view has an address
|
||||
|
||||
Paths, not a hash: `/quantus/day`, `/quantus/block/13160`,
|
||||
`/quantus/miner/0x…`. A fragment is never sent to a server, so a hash URL cannot
|
||||
be given a title, cannot be crawled, and cannot be resolved by anything but the
|
||||
page itself — which was a fine trade while the whole surface was a chain and a
|
||||
window, and stopped being one once a block and a miner each became a page worth
|
||||
sending someone. Both vhosts already answer any path with `index.html`, so
|
||||
nothing outside the client changed. `lib/routes` holds the grammar, and
|
||||
`fromLegacyHash` rewrites the old form on load, so every `#/…` link already
|
||||
published still lands where it meant to.
|
||||
|
||||
Route segments are named — `/quantus/block/13160` rather than
|
||||
`/quantus/13160` — rather than told apart by shape. Shape works right up until
|
||||
two kinds of thing can look alike, and an SS58 address is arbitrary base58 with
|
||||
no rule keeping it clear of a window name forever.
|
||||
|
||||
Everything navigable is an `<a>`, including the chain chips and the window
|
||||
control. That is what "real URLs" has to mean in practice: middle-click opens
|
||||
the 24-hour view of another chain in a tab, right-click copies a link to a
|
||||
miner, and the back button walks the panels the way it walks pages. The one
|
||||
deliberate exception is a height resolving to its hash, which *replaces* rather
|
||||
than pushes — it is the same block under a better name, not a second place the
|
||||
reader has been.
|
||||
|
||||
## Data
|
||||
|
||||
Postgres on `magrathea.kosherinata.internal`, mTLS and passwordless — the host's
|
||||
|
||||
+49
-102
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* The page.
|
||||
*
|
||||
* One route, effectively: a chain, a window, and optionally one miner in focus.
|
||||
* State lives in the URL hash rather than a router, because the whole
|
||||
* navigable surface is `#/planck/day` and `#/miner/0x…` — pulling in a router
|
||||
* to parse two segments would be more code than parsing them.
|
||||
* A chain and a window, with at most one of a block, a miner or an account in
|
||||
* focus above the standings. All of it lives in the path — see `lib/routes` for
|
||||
* the grammar and why it is a path rather than a hash — so every view here can
|
||||
* be linked to, opened in a new tab, and returned to with the back button.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import type { Window as WindowName } from './api/generated/Window'
|
||||
import { BlockPanel } from './components/BlockPanel'
|
||||
import { BlockTicker } from './components/BlockTicker'
|
||||
import { ChainSwitcher } from './components/ChainSwitcher'
|
||||
@@ -17,105 +17,59 @@ import { Leaderboard } from './components/Leaderboard'
|
||||
import { MinerPanel } from './components/MinerPanel'
|
||||
import { StatBar } from './components/StatBar'
|
||||
import { seconds, windowSpan } from './lib/format'
|
||||
import { href, parse, WINDOWS } from './lib/routes'
|
||||
import { useObserver, usePinnedMiners, useWatch } from './lib/store'
|
||||
|
||||
const WINDOWS: { id: WindowName; label: string; blocks: number }[] = [
|
||||
{ id: 'hour', label: '1h', blocks: 600 },
|
||||
{ id: 'six_hours', label: '6h', blocks: 3600 },
|
||||
{ id: 'day', label: '24h', blocks: 14400 },
|
||||
{ id: 'week', label: '7d', blocks: 100800 },
|
||||
]
|
||||
|
||||
interface Route {
|
||||
chain: string | null
|
||||
window: WindowName
|
||||
miner: string | null
|
||||
block: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A block number, or a 32-byte hash.
|
||||
*
|
||||
* The second path segment is a window (`day`, `six_hours`, …) or a block, and
|
||||
* the two can never be confused: no window name is all digits or `0x` followed
|
||||
* by 64 hex characters. That is what lets `#/planck/day` and
|
||||
* `#/planck/1069799` share a shape without a discriminating prefix.
|
||||
*/
|
||||
function isBlockRef(segment: string): boolean {
|
||||
return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment)
|
||||
}
|
||||
|
||||
function parseHash(hash: string): Route {
|
||||
const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean)
|
||||
if (parts[0] === 'miner' && parts[1]) {
|
||||
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase(), block: null }
|
||||
}
|
||||
const chain = parts[0] ?? null
|
||||
const second = parts[1]
|
||||
if (second && isBlockRef(second)) {
|
||||
return { chain, window: 'six_hours', miner: null, block: second.toLowerCase() }
|
||||
}
|
||||
const window = WINDOWS.find((w) => w.id === second)?.id ?? 'six_hours'
|
||||
return { chain, window, miner: null, block: null }
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const state = useObserver()
|
||||
const [route, setRoute] = useState<Route>(() => parseHash(window.location.hash))
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { pinned } = usePinnedMiners()
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => setRoute(parseHash(window.location.hash))
|
||||
window.addEventListener('hashchange', onHash)
|
||||
return () => window.removeEventListener('hashchange', onHash)
|
||||
}, [])
|
||||
const route = useMemo(() => parse(location.pathname), [location.pathname])
|
||||
|
||||
// Default to the first chain the backend reports — which is the first one in
|
||||
// its config, so the operator decides what the front page shows.
|
||||
// Default to the busiest chain we can actually track — the list is ordered
|
||||
// by node count, and an untracked chain has nothing behind it.
|
||||
const chain = route.chain ?? state.chains.find((c) => c.tracking === 'full')?.id ?? null
|
||||
const fallbackChain = state.chains.find((c) => c.tracking === 'full')?.id ?? null
|
||||
const chain = route.chain ?? fallbackChain
|
||||
const info = useMemo(
|
||||
() => state.chains.find((c) => c.id === chain) ?? null,
|
||||
[state.chains, chain],
|
||||
)
|
||||
|
||||
// `/` is not a page, it is "whichever chain the operator put first" — a
|
||||
// question only the backend can answer, and only once it has answered the
|
||||
// chain list. Replacing rather than pushing keeps the bare domain out of the
|
||||
// back stack, where it would bounce the user straight forward again.
|
||||
useEffect(() => {
|
||||
if (!route.chain && fallbackChain) {
|
||||
navigate(href({ chain: fallbackChain, window: route.window }), { replace: true })
|
||||
}
|
||||
}, [route.chain, route.window, fallbackChain, navigate])
|
||||
|
||||
useWatch(chain, route.window)
|
||||
|
||||
const go = useCallback((next: Partial<Route>) => {
|
||||
const merged = { ...parseHash(window.location.hash), ...next }
|
||||
window.location.hash = merged.miner
|
||||
? `/miner/${merged.miner}`
|
||||
: merged.block
|
||||
? `/${merged.chain ?? ''}/${merged.block}`
|
||||
: `/${merged.chain ?? ''}/${merged.window}`
|
||||
}, [])
|
||||
|
||||
const selectMiner = useCallback((miner: string) => {
|
||||
// Keep the chain and window; the miner panel opens above the board rather
|
||||
// than replacing the page, so the standings stay visible behind it.
|
||||
setRoute((r) => ({ ...r, miner, block: null }))
|
||||
}, [])
|
||||
|
||||
const selectBlock = useCallback((block: string) => {
|
||||
setRoute((r) => ({ ...r, block, miner: null }))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* A height has resolved to its hash; put the hash in the address bar.
|
||||
*
|
||||
* `replaceState`, not a navigation: it is the same block under the name that
|
||||
* will still mean this block after a reorg, so it belongs in place of the
|
||||
* height rather than on top of it in the back stack. Route state is left
|
||||
* alone deliberately — rewriting it would re-render the panel with a new
|
||||
* `block` prop and fetch the same block a second time.
|
||||
* `replace`, not a navigation: it is the same block under the name that will
|
||||
* still mean this block after a reorg, so it belongs in place of the height
|
||||
* rather than on top of it in the back stack. The panel keeps rendering from
|
||||
* the height it was given — `BlockPanel` keys on its own prop — so this does
|
||||
* not refetch.
|
||||
*/
|
||||
const blockResolved = useCallback(
|
||||
(hash: string) => {
|
||||
if (!chain) return
|
||||
window.history.replaceState(null, '', `#/${chain}/${hash}`)
|
||||
navigate(href({ chain, block: hash }), { replace: true })
|
||||
},
|
||||
[chain],
|
||||
[chain, navigate],
|
||||
)
|
||||
|
||||
/** Back to the standings, keeping the window the panel was opened from. */
|
||||
const closePanel = useCallback(
|
||||
() => navigate(href({ chain, window: route.window })),
|
||||
[chain, route.window, navigate],
|
||||
)
|
||||
|
||||
const interval =
|
||||
@@ -136,18 +90,21 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="masthead-right">
|
||||
<div className="segmented" role="group" aria-label="Leaderboard window">
|
||||
{/* Links, not buttons: a window is part of the address, so "the
|
||||
last 24 hours on Quantus" is a thing to send someone rather than
|
||||
a control state that a shared link would drop. */}
|
||||
<nav className="segmented" aria-label="Leaderboard window">
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
<Link
|
||||
key={w.id}
|
||||
aria-pressed={w.id === route.window}
|
||||
to={href({ chain, window: w.id })}
|
||||
aria-current={w.id === route.window ? 'page' : undefined}
|
||||
title={`${w.blocks.toLocaleString('en-US')} blocks — ${windowSpan(w.blocks, interval)}`}
|
||||
onClick={() => go({ window: w.id, miner: null, block: null })}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className={`status status-${state.connection}`}>
|
||||
<span className="status-dot" />
|
||||
@@ -156,11 +113,7 @@ export default function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ChainSwitcher
|
||||
chains={state.chains}
|
||||
active={chain}
|
||||
onSelect={(id) => go({ chain: id, miner: null, block: null })}
|
||||
/>
|
||||
<ChainSwitcher chains={state.chains} active={chain} window={route.window} />
|
||||
|
||||
{info?.status === 'awaiting' && (
|
||||
<div className="banner banner-warn">
|
||||
@@ -189,8 +142,7 @@ export default function App() {
|
||||
block={route.block}
|
||||
targetSeconds={info?.target_block_time_seconds ?? 6}
|
||||
onResolved={blockResolved}
|
||||
onSelectMiner={selectMiner}
|
||||
onClose={() => go({ window: route.window, miner: null, block: null })}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -199,7 +151,7 @@ export default function App() {
|
||||
chain={chain}
|
||||
miner={route.miner}
|
||||
windowName={route.window}
|
||||
onClose={() => setRoute((r) => ({ ...r, miner: null }))}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -213,7 +165,7 @@ export default function App() {
|
||||
{pinned.length > 0 && ` · ${pinned.length} marked yours`}
|
||||
</span>
|
||||
</div>
|
||||
<Leaderboard rows={state.leaderboard} ready={state.ready} onSelect={selectMiner} />
|
||||
<Leaderboard rows={state.leaderboard} chain={chain} ready={state.ready} />
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
@@ -225,12 +177,7 @@ export default function App() {
|
||||
: 'measuring'}
|
||||
</span>
|
||||
</div>
|
||||
<BlockTicker
|
||||
blocks={state.blocks}
|
||||
chain={chain}
|
||||
onSelect={selectMiner}
|
||||
onSelectBlock={selectBlock}
|
||||
/>
|
||||
<BlockTicker blocks={state.blocks} chain={chain} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { BlockDetail } from '../api/generated/BlockDetail'
|
||||
import { RequestFailed, fetchBlock } from '../api/rest'
|
||||
import { ago, bigNumber, height as fmtHeight, seconds, shortMiner } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
|
||||
/** A label and its value, with the caveat that makes the value readable. */
|
||||
function Fact({
|
||||
@@ -54,7 +56,6 @@ export function BlockPanel({
|
||||
block,
|
||||
targetSeconds,
|
||||
onResolved,
|
||||
onSelectMiner,
|
||||
onClose,
|
||||
}: {
|
||||
chain: string
|
||||
@@ -64,7 +65,6 @@ export function BlockPanel({
|
||||
targetSeconds: number
|
||||
/** Called with the canonical hash once a height has resolved to one. */
|
||||
onResolved: (hash: string) => void
|
||||
onSelectMiner: (miner: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [detail, setDetail] = useState<BlockDetail | null>(null)
|
||||
@@ -149,7 +149,9 @@ export function BlockPanel({
|
||||
This block lost its height to a reorg. It is kept here because the observer saw it —
|
||||
the chain itself no longer serves losing blocks for long.{' '}
|
||||
{detail.canonical_hash && (
|
||||
<a href={`#/${chain}/${detail.canonical_hash}`}>See the block that won →</a>
|
||||
<Link to={href({ chain, block: detail.canonical_hash })}>
|
||||
See the block that won →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -164,16 +166,9 @@ export function BlockPanel({
|
||||
<div className="facts">
|
||||
<Fact label="Author" note={detail.confidence ? undefined : 'no telemetry name held'}>
|
||||
{detail.miner ? (
|
||||
<a
|
||||
href={`#/miner/${detail.miner}`}
|
||||
title={detail.miner}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectMiner(detail.miner as string)
|
||||
}}
|
||||
>
|
||||
<Link to={href({ chain, miner: detail.miner as string })} title={detail.miner}>
|
||||
{detail.display ?? shortMiner(detail.miner)}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
// Genesis carries no `pow_` digest, and neither would a header
|
||||
// shape this decoder does not know.
|
||||
@@ -240,9 +235,9 @@ export function BlockPanel({
|
||||
{detail.parent_hash && (
|
||||
<div>
|
||||
<span className="eyebrow">Parent</span>{' '}
|
||||
<a className="numeral" href={`#/${chain}/${detail.parent_hash}`}>
|
||||
<Link className="numeral" to={href({ chain, block: detail.parent_hash })}>
|
||||
{detail.parent_hash}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{detail.also_seen.length > 0 && (
|
||||
@@ -252,9 +247,9 @@ export function BlockPanel({
|
||||
</span>
|
||||
{detail.also_seen.map((h) => (
|
||||
<div key={h}>
|
||||
<a className="numeral" href={`#/${chain}/${h}`}>
|
||||
<Link className="numeral" to={href({ chain, block: h })}>
|
||||
{h}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,22 +8,20 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { RecentBlock } from '../api/generated/RecentBlock'
|
||||
import { ago, height, seconds } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
import { usePinnedMiners } from '../lib/store'
|
||||
|
||||
export function BlockTicker({
|
||||
blocks,
|
||||
chain,
|
||||
onSelect,
|
||||
onSelectBlock,
|
||||
}: {
|
||||
blocks: RecentBlock[]
|
||||
/** Needed to address a block; null before the first chain has resolved. */
|
||||
chain: string | null
|
||||
onSelect: (miner: string) => void
|
||||
onSelectBlock: (block: string) => void
|
||||
}) {
|
||||
const { isPinned } = usePinnedMiners()
|
||||
const seen = useRef<Set<number>>(new Set())
|
||||
@@ -63,26 +61,15 @@ export function BlockTicker({
|
||||
.join(' ')}
|
||||
>
|
||||
{chain ? (
|
||||
<a
|
||||
className="ticker-height"
|
||||
href={`#/${chain}/${block.height}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectBlock(String(block.height))
|
||||
}}
|
||||
>
|
||||
<Link className="ticker-height" to={href({ chain, block: String(block.height) })}>
|
||||
#{height(block.height)}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="ticker-height">#{height(block.height)}</span>
|
||||
)}
|
||||
<a
|
||||
href={`#/miner/${block.miner}`}
|
||||
<Link
|
||||
to={href({ chain, miner: block.miner })}
|
||||
title={block.miner}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelect(block.miner)
|
||||
}}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -92,7 +79,7 @@ export function BlockTicker({
|
||||
}}
|
||||
>
|
||||
{block.display}
|
||||
</a>
|
||||
</Link>
|
||||
<span className="ticker-gap">
|
||||
{block.gap_seconds !== null
|
||||
? `+${seconds(block.gap_seconds)}`
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
* button that says why beats a navigable one that leads to an empty board.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { ChainInfo } from '../api/generated/ChainInfo'
|
||||
import type { Window as WindowName } from '../api/generated/Window'
|
||||
import { href } from '../lib/routes'
|
||||
|
||||
function reason(chain: ChainInfo): string {
|
||||
if (chain.tracking === 'no_endpoint') {
|
||||
@@ -33,11 +37,12 @@ function reason(chain: ChainInfo): string {
|
||||
export function ChainSwitcher({
|
||||
chains,
|
||||
active,
|
||||
onSelect,
|
||||
window: windowName,
|
||||
}: {
|
||||
chains: ChainInfo[]
|
||||
active: string | null
|
||||
onSelect: (id: string) => void
|
||||
/** Carried across the switch: changing chain is not a reason to change span. */
|
||||
window: WindowName
|
||||
}) {
|
||||
if (chains.length === 0) return null
|
||||
|
||||
@@ -48,18 +53,15 @@ export function ChainSwitcher({
|
||||
{chains.map((chain) => {
|
||||
const tracked = chain.tracking === 'full'
|
||||
const isActive = chain.id === active
|
||||
return (
|
||||
<button
|
||||
key={chain.genesis ?? chain.id}
|
||||
type="button"
|
||||
className={['chain-chip', isActive ? 'is-active' : '', tracked ? '' : 'is-untracked']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
disabled={!tracked}
|
||||
title={reason(chain)}
|
||||
onClick={() => tracked && onSelect(chain.id)}
|
||||
>
|
||||
const className = [
|
||||
'chain-chip',
|
||||
isActive ? 'is-active' : '',
|
||||
tracked ? '' : 'is-untracked',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
const label = (
|
||||
<>
|
||||
<span className="chain-name">{chain.display_name}</span>
|
||||
{chain.node_count !== null && (
|
||||
<span className="chain-nodes" title={`${chain.node_count} nodes on telemetry`}>
|
||||
@@ -70,6 +72,32 @@ export function ChainSwitcher({
|
||||
"greyed out" reads as "loading" at least as often as it reads
|
||||
as "unavailable". */}
|
||||
{!tracked && <span className="chain-note">no endpoint</span>}
|
||||
</>
|
||||
)
|
||||
// A link, not a button, for the chains that go somewhere: a chain is
|
||||
// a page with an address, and the chip is how most people reach it —
|
||||
// so it has to survive a middle click and a right-click-copy like
|
||||
// any other link on the site. The untracked ones stay buttons
|
||||
// because a disabled link is not a thing HTML has.
|
||||
return tracked ? (
|
||||
<Link
|
||||
key={chain.genesis ?? chain.id}
|
||||
to={href({ chain: chain.id, window: windowName })}
|
||||
className={className}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
title={reason(chain)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={chain.genesis ?? chain.id}
|
||||
type="button"
|
||||
className={className}
|
||||
disabled
|
||||
title={reason(chain)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -11,22 +11,25 @@
|
||||
* explicit "YOU" chip — so identity never rests on colour alone.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { LeaderboardRow } from '../api/generated/LeaderboardRow'
|
||||
import { ago, hashrate, share } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
import { usePinnedMiners } from '../lib/store'
|
||||
|
||||
function Row({
|
||||
row,
|
||||
chain,
|
||||
max,
|
||||
mine,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
row: LeaderboardRow
|
||||
chain: string | null
|
||||
max: number
|
||||
mine: boolean
|
||||
onToggle: (miner: string) => void
|
||||
onSelect: (miner: string) => void
|
||||
}) {
|
||||
const named = row.attribution === 'telemetry'
|
||||
// A name this operator earned on another chain, under the same reward
|
||||
@@ -50,23 +53,19 @@ function Row({
|
||||
>
|
||||
{mine ? '★' : '☆'}
|
||||
</button>
|
||||
<a
|
||||
href={`#/miner/${row.miner}`}
|
||||
<Link
|
||||
to={href({ chain, miner: row.miner })}
|
||||
className={named || carried ? 'miner-name' : 'miner-name anonymous'}
|
||||
// The address first: it is the identifier the miner recognises. The
|
||||
// preimage follows because it is what the header actually carries
|
||||
// and the only one the chain asserts.
|
||||
title={row.address ? `${row.address}\n${row.miner}` : row.miner}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onSelect(row.miner)
|
||||
}}
|
||||
>
|
||||
{/* `display` already carries the abbreviated reward address when
|
||||
no name is known — the backend derives it, because the preimage
|
||||
is exact and unrecognisable. */}
|
||||
{row.display}
|
||||
</a>
|
||||
</Link>
|
||||
{mine && <span className="chip chip-you">You</span>}
|
||||
{carried && (
|
||||
<span
|
||||
@@ -130,12 +129,13 @@ function Row({
|
||||
|
||||
export function Leaderboard({
|
||||
rows,
|
||||
chain,
|
||||
ready,
|
||||
onSelect,
|
||||
}: {
|
||||
rows: LeaderboardRow[]
|
||||
/** Needed to address a miner; null before the first chain has resolved. */
|
||||
chain: string | null
|
||||
ready: boolean
|
||||
onSelect: (miner: string) => void
|
||||
}) {
|
||||
const { isPinned, toggle } = usePinnedMiners()
|
||||
// Bars are scaled to the leader, not to 100%: with twenty miners the top
|
||||
@@ -182,8 +182,8 @@ export function Leaderboard({
|
||||
row={row}
|
||||
max={max}
|
||||
mine={isPinned(row.miner)}
|
||||
chain={chain}
|
||||
onToggle={toggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+8
-9
@@ -241,8 +241,10 @@ a {
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
.segmented a {
|
||||
appearance: none;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
@@ -255,25 +257,20 @@ a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.segmented button + button {
|
||||
.segmented a + a {
|
||||
border-left: var(--rule);
|
||||
}
|
||||
|
||||
.segmented button:hover:not(:disabled) {
|
||||
.segmented a:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.segmented button[aria-pressed='true'] {
|
||||
.segmented a[aria-current='page'] {
|
||||
background: var(--data-wash);
|
||||
color: var(--data-bright);
|
||||
}
|
||||
|
||||
.segmented button:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- chain nav ----------------------------------------------------------- */
|
||||
|
||||
.chains {
|
||||
@@ -296,6 +293,8 @@ a {
|
||||
|
||||
.chain-chip {
|
||||
display: inline-flex;
|
||||
/* The tracked chips are links now, so they arrive underlined. */
|
||||
text-decoration: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
appearance: none;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The site's URL grammar, in one place.
|
||||
*
|
||||
* Real paths, not a hash. The hash form was fine while the whole navigable
|
||||
* surface was a chain and a window, but a URL that a server never sees cannot
|
||||
* be given a title, cannot be crawled, and cannot be linked to from anywhere
|
||||
* that resolves links — and now that a block, a miner and an account each have
|
||||
* a page worth sending someone, that matters. Both vhosts already answer any
|
||||
* path with `index.html` (`try_files $uri $uri/ /index.html`), so the change is
|
||||
* entirely in the client.
|
||||
*
|
||||
* Segments are explicit — `/quantus/block/13160`, not `/quantus/13160` — rather
|
||||
* than discriminated by shape. Shape works until two kinds of thing can look
|
||||
* alike, and an SS58 address is arbitrary base58: there is no rule that keeps
|
||||
* it clear of a window name forever. Naming the kind costs six characters and
|
||||
* removes the whole class of problem.
|
||||
*/
|
||||
|
||||
import type { Window as WindowName } from '../api/generated/Window'
|
||||
|
||||
export const WINDOWS: { id: WindowName; label: string; blocks: number }[] = [
|
||||
{ id: 'hour', label: '1h', blocks: 600 },
|
||||
{ id: 'six_hours', label: '6h', blocks: 3600 },
|
||||
{ id: 'day', label: '24h', blocks: 14400 },
|
||||
{ id: 'week', label: '7d', blocks: 100800 },
|
||||
]
|
||||
|
||||
export const DEFAULT_WINDOW: WindowName = 'six_hours'
|
||||
|
||||
/** What the address bar currently says the page should show. */
|
||||
export interface Route {
|
||||
/** `null` means "whichever chain the backend puts first". */
|
||||
chain: string | null
|
||||
window: WindowName
|
||||
/** A height, or a `0x` block hash. */
|
||||
block: string | null
|
||||
/** A reward preimage. */
|
||||
miner: string | null
|
||||
/** An SS58 address. */
|
||||
account: string | null
|
||||
}
|
||||
|
||||
export const EMPTY: Route = {
|
||||
chain: null,
|
||||
window: DEFAULT_WINDOW,
|
||||
block: null,
|
||||
miner: null,
|
||||
account: null,
|
||||
}
|
||||
|
||||
function asWindow(segment: string | undefined): WindowName | null {
|
||||
return WINDOWS.find((w) => w.id === segment)?.id ?? null
|
||||
}
|
||||
|
||||
/** A block number, or a 32-byte hash. */
|
||||
export function isBlockRef(segment: string): boolean {
|
||||
return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a pathname into a route.
|
||||
*
|
||||
* Deliberately total: an unrecognised path degrades to the front page rather
|
||||
* than a 404, because every path here is a guess about live chain data — a
|
||||
* height that has not happened yet, a chain that was renamed — and a blank
|
||||
* error page is a worse answer than the standings.
|
||||
*/
|
||||
export function parse(pathname: string): Route {
|
||||
const parts = pathname.split('/').filter(Boolean)
|
||||
|
||||
// The legacy chainless miner link, kept working: the preimage identifies the
|
||||
// same account on every Quantus chain, so the only thing it lacks is which
|
||||
// chain's figures to show, and the default is the right guess.
|
||||
if (parts[0] === 'miner' && parts[1]) {
|
||||
return { ...EMPTY, miner: parts[1].toLowerCase() }
|
||||
}
|
||||
|
||||
const chain = parts[0] ?? null
|
||||
if (!chain) return EMPTY
|
||||
|
||||
const kind = parts[1]
|
||||
const value = parts[2]
|
||||
if (kind && value) {
|
||||
if (kind === 'block') return { ...EMPTY, chain, block: value.toLowerCase() }
|
||||
if (kind === 'miner') return { ...EMPTY, chain, miner: value.toLowerCase() }
|
||||
if (kind === 'account') return { ...EMPTY, chain, account: value }
|
||||
}
|
||||
// A bare height or hash as the second segment: the shape the site used
|
||||
// before this module existed. Recognised so links already sent stay good.
|
||||
if (kind && isBlockRef(kind)) return { ...EMPTY, chain, block: kind.toLowerCase() }
|
||||
|
||||
return { ...EMPTY, chain, window: asWindow(kind) ?? DEFAULT_WINDOW }
|
||||
}
|
||||
|
||||
/** The canonical path for a route. The inverse of {@link parse}. */
|
||||
export function href(route: Partial<Route>): string {
|
||||
const chain = route.chain ?? ''
|
||||
if (route.block) return `/${chain}/block/${route.block}`
|
||||
if (route.miner) return chain ? `/${chain}/miner/${route.miner}` : `/miner/${route.miner}`
|
||||
if (route.account) return `/${chain}/account/${route.account}`
|
||||
return `/${chain}/${route.window ?? DEFAULT_WINDOW}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a legacy hash URL meant, or `null` if there is nothing to migrate.
|
||||
*
|
||||
* Every link to this site published before today is `#/quantus/day` or
|
||||
* `#/quantus/1069799`, and a fragment is never sent to the server, so nothing
|
||||
* but the page itself can honour them. Rewriting on load with `replaceState`
|
||||
* keeps them working without leaving the old form in the back stack.
|
||||
*/
|
||||
export function fromLegacyHash(hash: string): string | null {
|
||||
if (!hash.startsWith('#/')) return null
|
||||
return href(parse(hash.slice(1)))
|
||||
}
|
||||
+11
-1
@@ -1,11 +1,19 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
import App from './App'
|
||||
import { Observer } from './api/socket'
|
||||
import { ObserverProvider } from './lib/ObserverProvider'
|
||||
import { fromLegacyHash } from './lib/routes'
|
||||
import './index.css'
|
||||
|
||||
// Before the router sees the URL, not after: `BrowserRouter` reads
|
||||
// `location.pathname` on mount and would otherwise render the front page while
|
||||
// the fragment sat in the address bar, unread and unexplained.
|
||||
const migrated = fromLegacyHash(window.location.hash)
|
||||
if (migrated) window.history.replaceState(null, '', migrated)
|
||||
|
||||
// One socket per tab, created here rather than inside a component so React 19's
|
||||
// double-invoked StrictMode effects cannot open two.
|
||||
const observer = new Observer()
|
||||
@@ -16,7 +24,9 @@ if (!root) throw new Error('#root is missing from index.html')
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ObserverProvider observer={observer}>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ObserverProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user