Dashboard polish: per-user modal, block-found animation, readable chart
Block-reward tile was cached for 60s, so users saw it sit still even as bitcoind's CreateNewBlock fired every few seconds with updated fee totals. Drop the template cache TTL to 15s — bitcoind caches the template internally, so the extra RPC cost is trivial. Hashrate chart was rendering 1440 raw per-minute points across a ~780px plot, which collapsed into a noisy smear. Bucket-average to ~96 points so the chart actually communicates a trend. Raw samples under the target pass through unchanged (early process lifetime). Layout: Blocks found is now full-width, with the Best shares leaderboard stacked below it. The previous side-by-side was squeezing both tables on typical displays. New user-detail modal. Clicking a BTC address in the Miners table opens a focused view: per-user aggregate hashrate (1m/5m/1h/24h), best round / best ever, and a worker-level breakdown with the same columns as the main table. Driven by a small selection store so any component in the tree can open it. Closes on backdrop click or Esc. New block-found animation. When the network tip advances, a brief full-screen overlay plays: a radial Hinokami Kagura ember burst above the header, a faint sun-ray sweep, and ~22 falling sakura petals with randomised drift / rotation / delay so no two blocks look identical. The existing block-height tile flash still fires alongside; the overlay is pointer-events: none so nothing in the UI becomes unreachable during the ~3.6s animation.
This commit is contained in:
@@ -340,9 +340,11 @@ func (a *Aggregator) refresh(ctx context.Context) {
|
||||
copy(next.HashrateHistory, a.hrHistory)
|
||||
}
|
||||
|
||||
// --- next-block reward (at most once per minute) ---
|
||||
// --- next-block reward (refreshed every ~15s so users see the
|
||||
// fee component tick up as mempool grows; bitcoind caches the
|
||||
// template internally, so the RPC is cheap even at this cadence).
|
||||
next.NextBlockRewardBTC = a.nextBlockReward
|
||||
needTemplate := now.Sub(a.lastTemplateFetch) >= time.Minute
|
||||
needTemplate := now.Sub(a.lastTemplateFetch) >= 15*time.Second
|
||||
a.mu.Unlock()
|
||||
|
||||
if needTemplate && a.RPC != nil && next.BitcoinOK {
|
||||
|
||||
+7
-4
@@ -7,6 +7,8 @@
|
||||
import MinersTable from "./lib/MinersTable.svelte";
|
||||
import BlocksTable from "./lib/BlocksTable.svelte";
|
||||
import BestShares from "./lib/BestShares.svelte";
|
||||
import UserDetailModal from "./lib/UserDetailModal.svelte";
|
||||
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
|
||||
|
||||
onMount(() => {
|
||||
connect();
|
||||
@@ -27,14 +29,15 @@
|
||||
{:else}
|
||||
<PoolOverview />
|
||||
<HashrateChart />
|
||||
<div class="grid grid-2">
|
||||
<BlocksTable />
|
||||
<BestShares />
|
||||
</div>
|
||||
<BlocksTable />
|
||||
<BestShares />
|
||||
<MinersTable />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<UserDetailModal />
|
||||
<BlockFoundAnimation />
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 1280px;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
|
||||
// One entry per falling petal; (re)generated on each block-found
|
||||
// trigger so the motion isn't perfectly synchronised across blocks.
|
||||
type Petal = {
|
||||
id: number;
|
||||
left: number; // viewport %
|
||||
delay: number; // seconds before this petal starts falling
|
||||
duration: number; // seconds for the full fall
|
||||
drift: number; // horizontal drift in vw over the fall
|
||||
rotate: number; // final rotation in deg
|
||||
scale: number; // final render scale
|
||||
hue: number; // pink/red hue shift
|
||||
};
|
||||
|
||||
const ANIMATION_MS = 3600;
|
||||
const PETAL_COUNT = 22;
|
||||
|
||||
let active = $state(false);
|
||||
let petals = $state<Petal[]>([]);
|
||||
let height = $state(0);
|
||||
|
||||
const currentHeight = $derived(snap.data?.chain?.blocks ?? 0);
|
||||
|
||||
$effect(() => {
|
||||
const h = currentHeight;
|
||||
const prev = untrack(() => height);
|
||||
if (h > 0 && prev > 0 && h !== prev) {
|
||||
trigger();
|
||||
}
|
||||
height = h;
|
||||
});
|
||||
|
||||
function trigger(): void {
|
||||
active = true;
|
||||
petals = Array.from({ length: PETAL_COUNT }, (_, i) => ({
|
||||
id: Date.now() + i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 0.6,
|
||||
duration: 2.2 + Math.random() * 1.1,
|
||||
drift: (Math.random() - 0.5) * 24,
|
||||
rotate: (Math.random() - 0.5) * 720,
|
||||
scale: 0.6 + Math.random() * 0.8,
|
||||
hue: -10 + Math.random() * 30,
|
||||
}));
|
||||
setTimeout(() => {
|
||||
active = false;
|
||||
petals = [];
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if active}
|
||||
<div class="overlay" aria-hidden="true">
|
||||
<!-- Radial ember burst from the top centre where the header sits;
|
||||
fills the viewport briefly in Hinokami Kagura orange/red. -->
|
||||
<div class="burst"></div>
|
||||
<!-- A faint sun-ray sweep rotating once behind the content. -->
|
||||
<div class="rays"></div>
|
||||
<!-- Individually-animated petals falling across the viewport. -->
|
||||
{#each petals as p (p.id)}
|
||||
<span
|
||||
class="petal"
|
||||
style="
|
||||
left: {p.left}vw;
|
||||
animation-delay: {p.delay}s;
|
||||
animation-duration: {p.duration}s;
|
||||
--drift: {p.drift}vw;
|
||||
--rot: {p.rotate}deg;
|
||||
--scale: {p.scale};
|
||||
--hue: {p.hue}deg;
|
||||
"
|
||||
>
|
||||
<!-- Five-petal cherry-blossom silhouette. -->
|
||||
<svg viewBox="0 0 32 32">
|
||||
<g fill="currentColor">
|
||||
{#each [0, 72, 144, 216, 288] as a}
|
||||
<ellipse
|
||||
cx="16" cy="7" rx="4.5" ry="8"
|
||||
transform="rotate({a} 16 16)"
|
||||
/>
|
||||
{/each}
|
||||
<circle cx="16" cy="16" r="1.6" fill="#6b1f1f" />
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
/* Radial ember burst. Starts bright, expands, and fades. */
|
||||
.burst {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -20vh;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 40vw;
|
||||
height: 40vw;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle, rgba(255, 160, 70, 0.75) 0%,
|
||||
rgba(255, 90, 30, 0.45) 30%,
|
||||
rgba(180, 40, 20, 0.18) 55%,
|
||||
transparent 75%);
|
||||
filter: blur(20px);
|
||||
animation: burst 1.6s ease-out forwards;
|
||||
}
|
||||
@keyframes burst {
|
||||
0% { transform: translate(-50%, -50%) scale(0.2); opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
100% { transform: translate(-50%, -50%) scale(6); opacity: 0; }
|
||||
}
|
||||
|
||||
/* A spinning sun-ray pattern. Subtle — doesn't dominate the page. */
|
||||
.rays {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 200vmax;
|
||||
height: 200vmax;
|
||||
transform: translate(-50%, -50%);
|
||||
background: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(255, 140, 50, 0.08) 0deg 4deg,
|
||||
transparent 4deg 14deg
|
||||
);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
animation: rays 2.4s ease-out forwards;
|
||||
}
|
||||
@keyframes rays {
|
||||
0% { opacity: 0; transform: translate(-50%, -50%) rotate(-20deg); }
|
||||
20% { opacity: 0.8; }
|
||||
100% { opacity: 0; transform: translate(-50%, -50%) rotate(40deg); }
|
||||
}
|
||||
|
||||
/* Falling sakura petals. Each gets a slightly different path via
|
||||
* CSS custom properties for drift / rotation / scale. */
|
||||
.petal {
|
||||
position: absolute;
|
||||
top: -8vh;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: hsl(340, 70%, 78%);
|
||||
filter:
|
||||
hue-rotate(var(--hue, 0deg))
|
||||
drop-shadow(0 1px 2px rgba(200, 60, 90, 0.35));
|
||||
transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
|
||||
opacity: 0;
|
||||
animation-name: petalFall;
|
||||
animation-timing-function: cubic-bezier(0.4, 0.1, 0.4, 1);
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
.petal svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@keyframes petalFall {
|
||||
0% { transform: translateX(0) translateY(0) rotate(0) scale(var(--scale, 1));
|
||||
opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
100% { transform: translateX(var(--drift, 0)) translateY(110vh) rotate(var(--rot, 0))
|
||||
scale(var(--scale, 1));
|
||||
opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.burst, .rays, .petal {
|
||||
animation-duration: 0.6s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,32 @@
|
||||
const plotW = W - PAD.left - PAD.right;
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
|
||||
const points = $derived(snap.data?.hashrate_history ?? []);
|
||||
const rawPoints = $derived(snap.data?.hashrate_history ?? []);
|
||||
|
||||
// Bucket raw per-minute samples into ~96 time buckets for a readable
|
||||
// 24h chart. Averaging each bucket smooths out short-term jitter
|
||||
// without losing the trend. When we have fewer raw points than the
|
||||
// target (early in the process's life), just pass them through.
|
||||
const TARGET_POINTS = 96;
|
||||
const points = $derived.by(() => {
|
||||
if (rawPoints.length <= TARGET_POINTS) return rawPoints;
|
||||
const bucketSize = Math.ceil(rawPoints.length / TARGET_POINTS);
|
||||
const out: { t: number; v: number }[] = [];
|
||||
for (let i = 0; i < rawPoints.length; i += bucketSize) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
let tSum = 0;
|
||||
for (let j = i; j < Math.min(i + bucketSize, rawPoints.length); j++) {
|
||||
sum += rawPoints[j].v;
|
||||
tSum += rawPoints[j].t;
|
||||
count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
out.push({ t: Math.round(tSum / count), v: sum / count });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const chart = $derived.by(() => {
|
||||
if (points.length < 2) return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { selectUser } from "../stores/selection.svelte";
|
||||
import { formatHashrate, formatDifficulty, formatAgo, detectHardware } from "../format";
|
||||
import type { StratumClient, Worker } from "../types";
|
||||
|
||||
@@ -97,7 +98,12 @@
|
||||
<tr class:offline={!r.online} class:idle={r.idle}>
|
||||
<td>
|
||||
<div class="wname mono">{r.workerName}</div>
|
||||
<div class="addr">{r.address}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="addr-btn mono"
|
||||
onclick={() => selectUser(r.address)}
|
||||
title="View per-user stats"
|
||||
>{r.address}</button>
|
||||
</td>
|
||||
<td>{r.hardware}</td>
|
||||
<td class="num">{formatHashrate(r.hashrate1m)}</td>
|
||||
@@ -130,10 +136,23 @@
|
||||
.wname {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.addr {
|
||||
.addr-btn {
|
||||
display: inline-block;
|
||||
color: var(--fg-dim);
|
||||
font-size: 0.8em;
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed transparent;
|
||||
}
|
||||
.addr-btn:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
tr.offline {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
<script lang="ts">
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { selection, clearSelection } from "../stores/selection.svelte";
|
||||
import {
|
||||
formatHashrate,
|
||||
formatDifficulty,
|
||||
formatAgo,
|
||||
detectHardware,
|
||||
} from "../format";
|
||||
import type { User, Worker, StratumClient } from "../types";
|
||||
|
||||
const address = $derived(selection.user);
|
||||
|
||||
// User row from snapshot.users matching the selected address.
|
||||
const user = $derived.by<User | null>(() => {
|
||||
if (!address) return null;
|
||||
return (snap.data?.users ?? []).find((u) => u.user === address) ?? null;
|
||||
});
|
||||
|
||||
// All workers + live stratum clients for this user.
|
||||
const workerRows = $derived.by(() => {
|
||||
if (!address) return [];
|
||||
const workers = (snap.data?.workers ?? []).filter((w) => w.user === address);
|
||||
const clients = (snap.data?.clients ?? []).filter(
|
||||
(c: StratumClient) => c.address === address,
|
||||
);
|
||||
const clientByWorker = new Map<string, StratumClient>();
|
||||
for (const c of clients) {
|
||||
const wname = c.workername || `${c.address}.unnamed`;
|
||||
clientByWorker.set(wname, c);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const rows: Array<{
|
||||
worker: string;
|
||||
hardware: string;
|
||||
hashrate1m: number;
|
||||
hashrate1h: number;
|
||||
diff: number;
|
||||
bestRound: number;
|
||||
bestEver: number;
|
||||
lastShare: number;
|
||||
online: boolean;
|
||||
idle: boolean;
|
||||
}> = [];
|
||||
for (const w of workers as Worker[]) {
|
||||
const c = clientByWorker.get(w.worker);
|
||||
seen.add(w.worker);
|
||||
rows.push({
|
||||
worker: w.worker,
|
||||
hardware: c ? detectHardware(c.useragent) : "offline",
|
||||
hashrate1m: (c?.dsps1 ?? w.dsps1) * 2 ** 32,
|
||||
hashrate1h: (c?.dsps60 ?? w.dsps60) * 2 ** 32,
|
||||
diff: c?.diff ?? w.mindiff,
|
||||
bestRound: c?.bestdiff ?? w.bestdiff,
|
||||
bestEver: w.bestever,
|
||||
lastShare: c?.lastshare ?? w.lastshare,
|
||||
online: !!c,
|
||||
idle: !!(c?.idle ?? w.idle),
|
||||
});
|
||||
}
|
||||
// Any live client without a matching worker (rare — usually
|
||||
// means workers[] hasn't refreshed yet).
|
||||
for (const c of clients) {
|
||||
const wname = c.workername || `${c.address}.unnamed`;
|
||||
if (seen.has(wname)) continue;
|
||||
rows.push({
|
||||
worker: wname,
|
||||
hardware: detectHardware(c.useragent),
|
||||
hashrate1m: c.dsps1 * 2 ** 32,
|
||||
hashrate1h: c.dsps60 * 2 ** 32,
|
||||
diff: c.diff,
|
||||
bestRound: c.bestdiff,
|
||||
bestEver: 0,
|
||||
lastShare: c.lastshare,
|
||||
online: true,
|
||||
idle: c.idle,
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.hashrate1h - a.hashrate1h);
|
||||
return rows;
|
||||
});
|
||||
|
||||
// Per-user aggregates, falling back to summed worker values if the
|
||||
// server hasn't populated the user row yet.
|
||||
const totals = $derived.by(() => {
|
||||
if (user) {
|
||||
return {
|
||||
hs1m: user.dsps1 * 2 ** 32,
|
||||
hs5m: user.dsps5 * 2 ** 32,
|
||||
hs1h: user.dsps60 * 2 ** 32,
|
||||
hs24h: user.dsps1440 * 2 ** 32,
|
||||
bestDiff: user.bestdiff,
|
||||
bestEver: user.bestever,
|
||||
workers: user.workers,
|
||||
lastShare: user.lastshare,
|
||||
};
|
||||
}
|
||||
let hs1m = 0, hs1h = 0;
|
||||
let bestDiff = 0, bestEver = 0, lastShare = 0;
|
||||
for (const r of workerRows) {
|
||||
hs1m += r.hashrate1m;
|
||||
hs1h += r.hashrate1h;
|
||||
if (r.bestRound > bestDiff) bestDiff = r.bestRound;
|
||||
if (r.bestEver > bestEver) bestEver = r.bestEver;
|
||||
if (r.lastShare > lastShare) lastShare = r.lastShare;
|
||||
}
|
||||
return {
|
||||
hs1m, hs5m: 0, hs1h, hs24h: 0,
|
||||
bestDiff, bestEver,
|
||||
workers: workerRows.length,
|
||||
lastShare,
|
||||
};
|
||||
});
|
||||
|
||||
function onBackdrop(ev: MouseEvent): void {
|
||||
if (ev.target === ev.currentTarget) clearSelection();
|
||||
}
|
||||
function onKey(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") clearSelection();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if address}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
onclick={onBackdrop}
|
||||
>
|
||||
<div class="modal card">
|
||||
<header class="head">
|
||||
<div class="title">
|
||||
<div class="stat-label">User</div>
|
||||
<div class="addr mono">{address}</div>
|
||||
</div>
|
||||
<button class="close" onclick={clearSelection} aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<section class="totals grid-3">
|
||||
<div>
|
||||
<div class="stat-label">Hashrate</div>
|
||||
<div class="stat-value">{formatHashrate(totals.hs1m)}</div>
|
||||
<div class="stat-sub">
|
||||
{#if totals.hs5m > 0}5m {formatHashrate(totals.hs5m)} · {/if}
|
||||
1h {formatHashrate(totals.hs1h)}
|
||||
{#if totals.hs24h > 0} · 24h {formatHashrate(totals.hs24h)}{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-label">Best share</div>
|
||||
<div class="stat-value">{formatDifficulty(totals.bestEver || totals.bestDiff)}</div>
|
||||
<div class="stat-sub">round {formatDifficulty(totals.bestDiff)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-label">Workers</div>
|
||||
<div class="stat-value">{totals.workers}</div>
|
||||
<div class="stat-sub">last share {formatAgo(totals.lastShare)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Workers</h3>
|
||||
{#if workerRows.length === 0}
|
||||
<div class="empty">No worker data yet for this address.</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Hardware</th>
|
||||
<th class="num">Hashrate (1m)</th>
|
||||
<th class="num">Hashrate (1h)</th>
|
||||
<th class="num">Diff</th>
|
||||
<th class="num">Best (round)</th>
|
||||
<th class="num">Best (ever)</th>
|
||||
<th class="num">Last share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each workerRows as r (r.worker)}
|
||||
<tr class:offline={!r.online} class:idle={r.idle}>
|
||||
<td class="mono">{r.worker}</td>
|
||||
<td>{r.hardware}</td>
|
||||
<td class="num">{formatHashrate(r.hashrate1m)}</td>
|
||||
<td class="num">{formatHashrate(r.hashrate1h)}</td>
|
||||
<td class="num">{formatDifficulty(r.diff)}</td>
|
||||
<td class="num">{formatDifficulty(r.bestRound)}</td>
|
||||
<td class="num">{formatDifficulty(r.bestEver)}</td>
|
||||
<td class="num">{formatAgo(r.lastShare)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(3, 6, 10, 0.72);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 4rem 1rem;
|
||||
z-index: 200;
|
||||
animation: fade 0.2s ease-out;
|
||||
}
|
||||
.modal {
|
||||
width: min(960px, 100%);
|
||||
max-height: calc(100vh - 8rem);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--accent-dim);
|
||||
box-shadow: 0 0 40px rgba(255, 122, 58, 0.18);
|
||||
animation: slide 0.25s ease-out;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.addr {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
.close {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-dim);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.close:hover {
|
||||
color: var(--fg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.totals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.totals {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
color: var(--fg-dim);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
tr.offline {
|
||||
opacity: 0.5;
|
||||
}
|
||||
tr.idle td {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
@keyframes fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes slide {
|
||||
from { transform: translateY(-12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
// 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`.
|
||||
|
||||
export const selection = $state<{ user: string | null }>({ user: null });
|
||||
|
||||
export function selectUser(address: string): void {
|
||||
selection.user = address;
|
||||
}
|
||||
|
||||
export function clearSelection(): void {
|
||||
selection.user = null;
|
||||
}
|
||||
Reference in New Issue
Block a user