feat(web): cyberpunk vue 3 dashboard with primal/amber/extension login

Frontend in @gashboard/web (Vue 3 + Vite + Pinia + TS, ~1.2k LoC):

Auth flow:
  - Two signing paths: NIP-07 browser extension (Alby/nos2x/Primal
    extension via window.nostr) and NIP-46 remote signer (Primal
    app, Amber, nsecbunker via bunker:// URI).
  - applesauce-signers lazy-loaded only on bunker login so users
    with NIP-07 don't pay the cost.
  - NIP-98 event built client-side, posted to /api/auth/login,
    JWT persisted in localStorage. Pinia auth store handles
    login/logout/state restore on reload.

Dashboard (composes the live /api/datum/stats poll, 5s):
  - PoolHero — combined hashrate as the headline number,
    block height, subscribed count, accepted/rejected shares.
  - LotteryWidget — rotating self-deprecating odds copy
    ("you're 0.3× as likely to find a block as get hit by
    lightning today"). Uses ~720 EH/s as the network-hashrate
    constant (TODO: fetch live).
  - ShareTicker — SVG sparkline of the last 60 polls.
  - MinerCard ×N — nickname (QU4CK/P1XEL/N4N0/M1N1), live
    hashrate, last share, lifetime tickets, reject %, status
    glow (green hashing / amber stale / red idle), affectionate
    roast subtitle per ASIC type.
  - BlockCelebration — full-screen overlay with celebration
    copy. Dormant for now (Datum's lastBlockFoundAt isn't
    surfaced yet); preview via window.gashboardCelebrate().

Cyberpunk theme:
  - Pure CSS vars, no Tailwind. Dark bg, neon cyan/magenta
    accents, monospace, glow shadows.
  - Optional CRT scanlines toggle (persists to localStorage).
  - Mobile-aware grid breakpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-05-06 15:59:00 +01:00
parent de353878f6
commit c56a47e2c4
18 changed files with 1430 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from "vue";
import { useStatsStore } from "../stores/stats";
import MinerCard from "../components/MinerCard.vue";
import PoolHero from "../components/PoolHero.vue";
import LotteryWidget from "../components/LotteryWidget.vue";
import ShareTicker from "../components/ShareTicker.vue";
import BlockCelebration from "../components/BlockCelebration.vue";
const stats = useStatsStore();
onMounted(() => stats.start());
onUnmounted(() => stats.stop());
const totalThs = computed(() => stats.snapshot?.pool.combinedHashrateThs ?? 0);
const miners = computed(() => stats.snapshot?.miners ?? []);
const errorMsg = computed(() => stats.error);
const upstreamErr = computed(() => stats.snapshot?.error);
</script>
<template>
<BlockCelebration :snapshot="stats.snapshot" />
<div v-if="errorMsg" class="banner err">
{{ errorMsg }}
</div>
<div v-else-if="upstreamErr" class="banner warn">
datum: {{ upstreamErr.code }} {{ upstreamErr.message }}
</div>
<PoolHero :snapshot="stats.snapshot" />
<div class="grid-row">
<LotteryWidget :total-ths="totalThs" />
<ShareTicker :snapshot="stats.snapshot" />
</div>
<div class="miners">
<MinerCard v-for="m in miners" :key="m.authUsername || m.nickname" :miner="m" />
<div v-if="!miners.length && !errorMsg" class="empty panel muted">
waiting for the first poll · the boards are warming up
</div>
</div>
</template>
<style scoped>
.banner {
padding: 10px 14px;
margin-bottom: 16px;
font-size: 12px;
border: 1px solid var(--line);
}
.banner.err { border-color: var(--neon-red); color: var(--neon-red); }
.banner.warn { border-color: var(--neon-amber); color: var(--neon-amber); }
.grid-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin: 16px 0;
}
.miners {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.empty {
padding: 40px;
text-align: center;
font-style: italic;
}
@media (max-width: 800px) {
.grid-row { grid-template-columns: 1fr; }
}
</style>