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:
2026-09-09 16:53:41 +03:00
co-authored by Claude Opus 5
parent eb26a7bb3c
commit 49c266a905
9 changed files with 286 additions and 179 deletions
+31 -5
View File
@@ -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
View File
@@ -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>
+11 -16
View File
@@ -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>
+7 -20
View File
@@ -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)}`
+42 -14
View File
@@ -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>
)
})}
+12 -12
View File
@@ -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
View File
@@ -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;
+115
View File
@@ -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
View File
@@ -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>,
)