From 49c951f94ffc26346724dacb8c8b6b3f935ddec5 Mon Sep 17 00:00:00 2001 From: satoshi Date: Fri, 24 Apr 2026 01:02:11 +0300 Subject: [PATCH] User page: hash-routed view, full block hash, aligned rewards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-user view was an overlay modal, but users asked for an actual page — focused, scrollable, addressable. Replace the modal with a hash-routed page: selectUser(addr) sets window.location.hash to #/user/ and a hashchange listener syncs the selection state back, so browser back/forward and direct-link refresh all work without a routing library. App renders the dashboard or the UserDetailPage based on selection.user. Make the clickable BTC addresses in the Miners table obviously interactive: accent colour, always-on dashed underline, brighter on hover/focus. The previous styling rendered them as dim static text with a hover underline, so it wasn't obvious they were links. BlocksTable: show the full 64-char block hash as a link to mempool.space (auto-picks testnet4/signet based on snap.chain). word-break: break-all keeps the hash from blowing out the column width. Reward column is split into a tabular-num number span and a dim 0.75em "BTC" unit span, matching the Block reward tile in the overview, so numbers line up cleanly across rows. --- ui/src/App.svelte | 6 +- ui/src/lib/BlocksTable.svelte | 100 +++++++--- ui/src/lib/MinersTable.svelte | 15 +- ui/src/lib/UserDetailModal.svelte | 302 ------------------------------ ui/src/lib/UserDetailPage.svelte | 263 ++++++++++++++++++++++++++ ui/src/stores/selection.svelte.ts | 48 ++++- 6 files changed, 397 insertions(+), 337 deletions(-) delete mode 100644 ui/src/lib/UserDetailModal.svelte create mode 100644 ui/src/lib/UserDetailPage.svelte diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 33d1568..ef9ee32 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,13 +1,14 @@
@@ -14,28 +22,49 @@ {#if blocks.length === 0}
No blocks found yet.
{:else} - - - - - - - - - - - - {#each blocks as b (b.height + "-" + b.found_at)} +
+
HeightHashRewardWinning shareWhen
+ - - - - - + + + + + - {/each} - -
{b.height}{b.hash ? b.hash.slice(0, 16) + "…" : "—"}{b.reward_btc ? b.reward_btc.toFixed(4) + " BTC" : "—"}{b.share_diff ? formatDifficulty(b.share_diff) : "—"}{formatAgo(new Date(b.found_at).getTime() / 1000)}HeightHashRewardWinning shareWhen
+ + + {#each blocks as b (b.height + "-" + b.found_at)} + + {b.height} + + {#if b.hash} + {b.hash} + {:else} + + {/if} + + + {#if b.reward_btc} + {b.reward_btc.toFixed(4)} + BTC + {:else} + + {/if} + + {b.share_diff ? formatDifficulty(b.share_diff) : "—"} + {formatAgo(new Date(b.found_at).getTime() / 1000)} + + {/each} + + + {/if}
@@ -49,7 +78,36 @@ color: var(--fg-dim); padding: 0.5rem 0; } + .table-wrap { + overflow-x: auto; + } + .hash-cell { + /* Let the 64-char hash wrap inside the cell instead of stretching + * the whole table. */ + max-width: 620px; + word-break: break-all; + } .hash { color: var(--fg-dim); + font-size: 0.85em; + line-height: 1.4; + } + a.hash { + color: var(--fg-dim); + text-decoration: none; + border-bottom: 1px dashed transparent; + } + a.hash:hover { + color: var(--accent); + border-bottom-color: var(--accent); + } + .reward-num { + font-variant-numeric: tabular-nums; + } + .unit { + color: var(--fg-dim); + font-size: 0.75em; + font-weight: 400; + margin-left: 0.25em; } diff --git a/ui/src/lib/MinersTable.svelte b/ui/src/lib/MinersTable.svelte index 6b293e2..923da3c 100644 --- a/ui/src/lib/MinersTable.svelte +++ b/ui/src/lib/MinersTable.svelte @@ -138,21 +138,24 @@ } .addr-btn { display: inline-block; - color: var(--fg-dim); - font-size: 0.8em; + color: var(--accent); + font-size: 0.82em; background: transparent; border: none; padding: 0; - margin: 0; + margin-top: 0.1em; cursor: pointer; text-align: left; text-decoration: none; - border-bottom: 1px dashed transparent; + border-bottom: 1px dashed var(--accent-dim); + transition: color 0.15s, border-bottom-color 0.15s; } - .addr-btn:hover { - color: var(--accent); + .addr-btn:hover, + .addr-btn:focus-visible { + color: #ff9a5f; border-bottom-color: var(--accent); background: transparent; + outline: none; } tr.offline { opacity: 0.4; diff --git a/ui/src/lib/UserDetailModal.svelte b/ui/src/lib/UserDetailModal.svelte deleted file mode 100644 index 6c2e220..0000000 --- a/ui/src/lib/UserDetailModal.svelte +++ /dev/null @@ -1,302 +0,0 @@ - - - - -{#if address} - - - -{/if} - - diff --git a/ui/src/lib/UserDetailPage.svelte b/ui/src/lib/UserDetailPage.svelte new file mode 100644 index 0000000..c616916 --- /dev/null +++ b/ui/src/lib/UserDetailPage.svelte @@ -0,0 +1,263 @@ + + + + +
+ + +
+
User
+

{address}

+
+ +
+
+
Hashrate
+
{formatHashrate(totals.hs1m)}
+
+ {#if totals.hs5m > 0}5m {formatHashrate(totals.hs5m)} · {/if} + 1h {formatHashrate(totals.hs1h)} + {#if totals.hs24h > 0} · 24h {formatHashrate(totals.hs24h)}{/if} +
+
+
+
Best share (ever)
+
{formatDifficulty(totals.bestEver || totals.bestDiff)}
+
round best {formatDifficulty(totals.bestDiff)}
+
+
+
Workers
+
{totals.workers}
+
{workerRows.filter(r => r.online).length} online
+
+
+
Last share
+
{formatAgo(totals.lastShare)}
+
across all this user's workers
+
+
+ +
+

Workers

+ {#if workerRows.length === 0} +
No worker data yet for this address.
+ {:else} +
+ + + + + + + + + + + + + + + {#each workerRows as r (r.worker)} + + + + + + + + + + + {/each} + +
WorkerHardwareHashrate (1m)Hashrate (1h)DiffBest (round)Best (ever)Last share
{r.worker}{r.hardware}{formatHashrate(r.hashrate1m)}{formatHashrate(r.hashrate1h)}{formatDifficulty(r.diff)}{formatDifficulty(r.bestRound)}{formatDifficulty(r.bestEver)}{formatAgo(r.lastShare)}
+
+ {/if} +
+
+ + diff --git a/ui/src/stores/selection.svelte.ts b/ui/src/stores/selection.svelte.ts index de18a09..c6be297 100644 --- a/ui/src/stores/selection.svelte.ts +++ b/ui/src/stores/selection.svelte.ts @@ -1,13 +1,49 @@ -// Cross-component selection state. Currently just tracks the user -// whose detail modal is open; App renders the modal off this store -// so any component in the tree can open it by setting `user`. +// Simple hash-based routing for user pages. The URL hash is the +// source of truth: +// #/ -> dashboard +// #/user/
-> per-user page +// +// selectUser / clearSelection just mutate the hash; a hashchange +// listener reads it back into reactive state so any component in the +// tree re-renders when the route changes. -export const selection = $state<{ user: string | null }>({ user: null }); +const USER_PREFIX = "#/user/"; + +export const selection = $state<{ user: string | null }>({ + user: readHash(), +}); + +function readHash(): string | null { + if (typeof window === "undefined") return null; + const h = window.location.hash; + if (h.startsWith(USER_PREFIX)) { + const addr = decodeURIComponent(h.slice(USER_PREFIX.length)); + return addr || null; + } + return null; +} + +function syncFromHash(): void { + selection.user = readHash(); +} + +if (typeof window !== "undefined") { + window.addEventListener("hashchange", syncFromHash); +} export function selectUser(address: string): void { - selection.user = address; + // Set the hash; the hashchange listener updates selection.user so + // routing-style state stays in one place. + window.location.hash = USER_PREFIX + encodeURIComponent(address); } export function clearSelection(): void { - selection.user = null; + // Prefer history.back when we arrived here via selectUser so the + // browser back arrow does the obvious thing; otherwise just drop + // the hash so a fresh page load starts on the dashboard. + if (window.history.length > 1 && window.location.hash.startsWith(USER_PREFIX)) { + window.history.back(); + } else { + window.location.hash = ""; + } }