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

101
apps/web/src/App.vue Normal file
View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { RouterView, useRoute } from "vue-router";
import { useAuthStore } from "./stores/auth";
const auth = useAuthStore();
const route = useRoute();
const crt = ref(false);
onMounted(() => {
const stored = localStorage.getItem("gashboard.crt") === "1";
crt.value = stored;
if (stored) document.body.classList.add("crt");
});
function toggleCrt(): void {
crt.value = !crt.value;
document.body.classList.toggle("crt", crt.value);
localStorage.setItem("gashboard.crt", crt.value ? "1" : "0");
}
function shortNpub(n: string): string {
return n.length > 16 ? `${n.slice(0, 8)}${n.slice(-4)}` : n;
}
</script>
<template>
<header class="topbar">
<div class="brand glow-magenta flicker">
GASHBOARD
<span class="muted">// solo lottery dashboard</span>
</div>
<div class="actions">
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
<template v-if="auth.isLoggedIn">
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
<button class="thin" @click="auth.logout()">logout</button>
</template>
</div>
</header>
<main :class="{ shellLogin: route.name === 'login' }">
<RouterView />
</main>
<footer class="footbar muted">
P(block) is a lifestyle, not a number · gashboard v0.1 · {{ new Date().getFullYear() }}
</footer>
</template>
<style scoped>
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--line);
background: rgba(7, 9, 15, 0.85);
backdrop-filter: blur(6px);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
font-weight: 700;
letter-spacing: 0.18em;
font-size: 15px;
}
.brand .muted {
margin-left: 12px;
letter-spacing: 0.04em;
font-weight: 400;
font-size: 11px;
}
.actions {
display: flex;
gap: 12px;
align-items: center;
}
button.thin {
padding: 4px 10px;
font-size: 11px;
}
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
main.shellLogin {
display: grid;
place-items: center;
min-height: calc(100vh - 110px);
}
.footbar {
padding: 14px 24px;
border-top: 1px solid var(--line);
font-size: 11px;
text-align: center;
letter-spacing: 0.08em;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import type { DatumSnapshot } from "../types";
import { BLOCK_CELEBRATION_LINES } from "../strings";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
const showing = ref(false);
const message = ref("");
let lastBlockFoundAt = 0;
watch(
() => props.snapshot,
(snap) => {
if (!snap) return;
// Block-found field not yet wired — placeholder for when the / homepage
// parser surfaces lastBlockFoundAt. For now this is dormant; trigger via
// window.gashboardCelebrate() in the console for a preview.
void lastBlockFoundAt; // silence unused
},
);
if (typeof window !== "undefined") {
(window as unknown as { gashboardCelebrate?: () => void }).gashboardCelebrate = () => {
const idx = Math.floor(Math.random() * BLOCK_CELEBRATION_LINES.length);
message.value = BLOCK_CELEBRATION_LINES[idx] ?? "";
showing.value = true;
setTimeout(() => (showing.value = false), 8000);
};
}
</script>
<template>
<Transition name="fade">
<div v-if="showing" class="overlay">
<div class="card glow-magenta">
<div class="title"> B L O C K F O U N D </div>
<div class="msg">{{ message }}</div>
<button @click="showing = false">acknowledge</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(7, 9, 15, 0.85);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
z-index: 1000;
}
.card {
text-align: center;
padding: 40px 50px;
border: 2px solid var(--neon-magenta);
background: var(--bg-1);
box-shadow: 0 0 40px var(--neon-magenta);
display: flex;
flex-direction: column;
gap: 18px;
max-width: 520px;
}
.title {
font-size: 24px;
letter-spacing: 0.4em;
font-weight: 700;
}
.msg {
font-size: 14px;
font-style: italic;
line-height: 1.5;
color: var(--fg-1);
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.4s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { LOTTERY_LINES } from "../strings";
const props = defineProps<{ totalThs: number }>();
// As of mid-2026, network hashrate is roughly ~720 EH/s. Hardcoded estimate;
// good enough for self-deprecating jokes about cosmic odds.
const NETWORK_EH_S = 720;
const SECONDS_PER_BLOCK = 600;
const SECONDS_PER_DAY = 86400;
const probPerBlock = computed(() => {
if (props.totalThs <= 0) return 0;
// Th/s vs EH/s = 1e6 conversion: 1 EH/s = 1e6 Th/s
return props.totalThs / (NETWORK_EH_S * 1e6);
});
const probPerDay = computed(() => {
// Probability of NOT finding in any of (86400/600) = 144 blocks per day
const blocksPerDay = SECONDS_PER_DAY / SECONDS_PER_BLOCK;
const p = probPerBlock.value;
return 1 - Math.pow(1 - p, blocksPerDay);
});
const lineIndex = ref(0);
let timer: number | null = null;
onMounted(() => {
timer = window.setInterval(() => {
lineIndex.value = (lineIndex.value + 1) % LOTTERY_LINES.length;
}, 7000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
const currentLine = computed(() => {
const fn = LOTTERY_LINES[lineIndex.value];
if (!fn) return "";
return fn(probPerDay.value);
});
</script>
<template>
<section class="lottery panel">
<div class="label">solo lottery odds</div>
<div class="line glow-amber">
<span class="prefix">$</span>
<span class="text">{{ currentLine }}</span>
</div>
<div class="dots">
<span
v-for="(_, i) in LOTTERY_LINES"
:key="i"
:class="['dot', { active: i === lineIndex }]"
/>
</div>
</section>
</template>
<style scoped>
.lottery {
display: flex;
flex-direction: column;
gap: 10px;
}
.line {
font-size: 13px;
display: flex;
gap: 10px;
align-items: baseline;
min-height: 32px;
}
.prefix { color: var(--neon-amber); font-weight: 700; }
.text { line-height: 1.5; }
.dots {
display: flex;
gap: 5px;
margin-top: 4px;
}
.dot {
width: 5px; height: 5px;
border-radius: 50%;
background: var(--line-bright);
transition: background 0.3s, box-shadow 0.3s;
}
.dot.active {
background: var(--neon-amber);
box-shadow: 0 0 8px var(--neon-amber);
}
</style>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed } from "vue";
import type { MinerStat } from "../types";
import { ROASTS, STATUS_LINES, STALE_LINES } from "../strings";
const props = defineProps<{ miner: MinerStat }>();
const slug = computed(() => {
// Map nickname → roast key
const map: Record<string, string> = {
QU4CK: "nerdqaxe",
P1XEL: "bitaxe",
N4N0: "avalon-nano-3",
M1N1: "avalon-mini-3",
};
return map[props.miner.nickname] ?? "unknown";
});
const roast = computed(() => ROASTS[slug.value] ?? ROASTS["unknown"]);
const statusLabel = computed(() => STATUS_LINES[props.miner.status]);
const statusGlow = computed(() => {
switch (props.miner.status) {
case "hashing": return "glow-green";
case "stale": return "glow-amber";
case "idle": return "glow-red";
}
});
const lastShareLabel = computed(() => {
const s = props.miner.lastShareAgeS;
if (s === null) return "no shares yet";
if (s < 60) return `${s.toFixed(0)}s ago`;
if (s < 3600) return `${(s / 60).toFixed(1)}m ago`;
return `${(s / 3600).toFixed(1)}h ago`;
});
const lifetimeShares = computed(() => props.miner.diffAcceptedCount);
const bestShare = computed(() => formatBig(props.miner.diffAcceptedSum));
const staleNote = computed(() => {
if (props.miner.status !== "stale") return "";
const idx = Math.floor(Math.random() * STALE_LINES.length);
return STALE_LINES[idx];
});
function formatBig(n: number): string {
if (n < 1_000) return n.toFixed(0);
if (n < 1e6) return `${(n / 1e3).toFixed(1)}K`;
if (n < 1e9) return `${(n / 1e6).toFixed(1)}M`;
if (n < 1e12) return `${(n / 1e9).toFixed(1)}B`;
return `${(n / 1e12).toFixed(1)}T`;
}
</script>
<template>
<div class="card panel" :data-status="miner.status">
<header>
<div class="nickname glow-magenta">{{ miner.nickname }}</div>
<div :class="['status', statusGlow]">
<span class="dot pulse" />
{{ statusLabel }}
</div>
</header>
<div class="model">{{ miner.model }}</div>
<div class="roast muted">{{ roast }}</div>
<div class="stats">
<div class="stat">
<div class="label">hashrate</div>
<div class="value glow-cyan">
{{ miner.hashrateThs.toFixed(2) }}
<span class="unit">Th/s</span>
</div>
</div>
<div class="stat">
<div class="label">last share</div>
<div class="value">{{ lastShareLabel }}</div>
</div>
<div class="stat">
<div class="label">tickets bought</div>
<div class="value">{{ lifetimeShares.toLocaleString() }}</div>
</div>
<div class="stat">
<div class="label">total work</div>
<div class="value">{{ bestShare }}</div>
</div>
<div class="stat">
<div class="label">reject %</div>
<div :class="['value', miner.rejectPct > 5 ? 'glow-red' : '']">
{{ miner.rejectPct.toFixed(2) }}%
</div>
</div>
<div class="stat">
<div class="label">vdiff</div>
<div class="value">{{ miner.vdiff.toLocaleString() }}</div>
</div>
</div>
<footer>
<span class="ua muted" :title="miner.userAgent">{{ miner.userAgent || "—" }}</span>
<span class="loc muted">@{{ miner.location }}</span>
</footer>
<div v-if="miner.status === 'stale'" class="stale-banner">
{{ staleNote }}
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
gap: 12px;
position: relative;
overflow: hidden;
}
.card[data-status="idle"] {
opacity: 0.6;
}
.card[data-status="stale"] {
border-color: var(--neon-amber);
}
.card[data-status="hashing"] {
border-color: var(--neon-green);
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.nickname {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.16em;
}
.status {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
display: flex;
gap: 6px;
align-items: center;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.model {
font-size: 12px;
color: var(--fg-1);
letter-spacing: 0.06em;
}
.roast {
font-size: 11.5px;
font-style: italic;
line-height: 1.4;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 14px;
padding: 10px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.stat .label {
font-size: 10px;
}
.stat .value {
font-size: 14px;
font-weight: 500;
}
.stat .unit {
font-size: 10px;
color: var(--fg-2);
}
footer {
display: flex;
justify-content: space-between;
font-size: 10.5px;
}
.ua { max-width: 65%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stale-banner {
font-size: 10.5px;
color: var(--neon-amber);
border-top: 1px dashed var(--neon-amber);
padding-top: 8px;
font-style: italic;
}
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot } from "../types";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
const subscribed = computed(() => props.snapshot?.pool.totalSubscriptions ?? 0);
const sharesAccepted = computed(() => props.snapshot?.pool.sharesAccepted ?? 0);
const sharesRejected = computed(() => props.snapshot?.pool.sharesRejected ?? 0);
const blockHeight = computed(() => props.snapshot?.job.blockHeight ?? 0);
const ageS = computed(() => {
const t = props.snapshot?.fetchedAt;
if (!t) return null;
return Math.floor((Date.now() - t) / 1000);
});
</script>
<template>
<section class="hero panel">
<div class="left">
<div class="label">total hashrate · all 4 little soldiers</div>
<div class="number glow-cyan">
{{ totalThs.toFixed(2) }}
<span class="unit">Th/s</span>
</div>
</div>
<div class="right">
<div class="cell">
<div class="label">block height</div>
<div class="value">{{ blockHeight > 0 ? blockHeight.toLocaleString() : "—" }}</div>
</div>
<div class="cell">
<div class="label">subscribed</div>
<div class="value">{{ subscribed }}</div>
</div>
<div class="cell">
<div class="label">accepted</div>
<div class="value glow-green">{{ sharesAccepted.toLocaleString() }}</div>
</div>
<div class="cell">
<div class="label">rejected</div>
<div class="value glow-red">{{ sharesRejected.toLocaleString() }}</div>
</div>
<div class="cell">
<div class="label">last poll</div>
<div class="value muted">{{ ageS === null ? "—" : `${ageS}s ago` }}</div>
</div>
</div>
</section>
</template>
<style scoped>
.hero {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 24px;
align-items: center;
}
.left .number {
font-size: 64px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1;
}
.left .unit {
font-size: 18px;
color: var(--fg-2);
}
.right {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.cell .value {
font-size: 18px;
font-weight: 600;
margin-top: 4px;
}
@media (max-width: 800px) {
.hero { grid-template-columns: 1fr; }
.right { grid-template-columns: repeat(2, 1fr); }
.left .number { font-size: 44px; }
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { DatumSnapshot } from "../types";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
// Simple sparkline: keep the last N hashrate samples and render as SVG.
const SAMPLES = 60;
const samples = ref<number[]>([]);
let lastFetchedAt = 0;
watch(
() => props.snapshot,
(snap) => {
if (!snap || snap.fetchedAt === lastFetchedAt) return;
lastFetchedAt = snap.fetchedAt;
samples.value.push(snap.pool.combinedHashrateThs);
if (samples.value.length > SAMPLES) samples.value.shift();
},
);
const path = computed(() => {
const ys = samples.value;
if (ys.length < 2) return "";
const max = Math.max(...ys, 0.001);
const w = 100;
const h = 32;
return ys
.map((y, i) => {
const x = (i / (SAMPLES - 1)) * w;
const yPx = h - (y / max) * h;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${yPx.toFixed(2)}`;
})
.join(" ");
});
</script>
<template>
<section class="ticker panel">
<div class="label">hashrate · last {{ SAMPLES }} polls</div>
<svg viewBox="0 0 100 32" preserveAspectRatio="none">
<path :d="path" stroke="var(--neon-cyan)" stroke-width="0.6" fill="none" />
</svg>
</section>
</template>
<style scoped>
.ticker { display: flex; flex-direction: column; gap: 6px; }
svg { width: 100%; height: 60px; }
</style>

10
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

28
apps/web/src/router.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { useAuthStore } from "./stores/auth";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "dashboard",
component: () => import("./views/DashboardView.vue"),
meta: { requiresAuth: true },
},
{
path: "/login",
name: "login",
component: () => import("./views/LoginView.vue"),
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isLoggedIn) return { name: "login" };
if (to.name === "login" && auth.isLoggedIn) return { name: "dashboard" };
return true;
});

View File

@@ -0,0 +1,66 @@
import type { ApiError, DatumSnapshot, LoginResponse } from "../types";
import { buildNip98AuthHeader } from "./nip98";
const TOKEN_KEY = "gashboard.jwt";
const NPUB_KEY = "gashboard.npub";
export function loadToken(): { token: string; npub: string } | null {
const token = localStorage.getItem(TOKEN_KEY);
const npub = localStorage.getItem(NPUB_KEY);
if (!token || !npub) return null;
return { token, npub };
}
export function saveToken(token: string, npub: string): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(NPUB_KEY, npub);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(NPUB_KEY);
}
async function asError(res: Response): Promise<Error> {
let body: ApiError | undefined;
try {
body = (await res.json()) as ApiError;
} catch {
/* ignore */
}
const code = body?.error?.code ?? `HTTP_${res.status}`;
const message = body?.error?.message ?? res.statusText;
const err = new Error(`${code}: ${message}`);
(err as Error & { code?: string }).code = code;
return err;
}
export async function login(): Promise<LoginResponse> {
const url = `${window.location.origin}/api/auth/login`;
const auth = await buildNip98AuthHeader("POST", url);
const res = await fetch(url, {
method: "POST",
headers: { authorization: auth, "content-type": "application/json" },
body: "{}",
});
if (!res.ok) throw await asError(res);
return (await res.json()) as LoginResponse;
}
async function authedGet<T>(path: string): Promise<T> {
const stored = loadToken();
if (!stored) throw new Error("Not logged in");
const res = await fetch(path, {
headers: { authorization: `Bearer ${stored.token}` },
});
if (res.status === 401) {
clearToken();
throw new Error("Session expired");
}
if (!res.ok) throw await asError(res);
return (await res.json()) as T;
}
export async function fetchSnapshot(): Promise<DatumSnapshot> {
return authedGet<DatumSnapshot>("/api/datum/stats");
}

View File

@@ -0,0 +1,21 @@
import type { EventTemplate } from "nostr-tools";
import { getActiveSigner } from "./signer";
export async function buildNip98AuthHeader(method: string, fullUrl: string): Promise<string> {
const signer = getActiveSigner();
if (!signer) throw new Error("Not logged in");
const template: EventTemplate = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", fullUrl],
["method", method.toUpperCase()],
],
content: "",
};
const signed = await signer.signEvent(template);
const json = JSON.stringify(signed);
const b64 = btoa(unescape(encodeURIComponent(json)));
return `Nostr ${b64}`;
}

View File

@@ -0,0 +1,68 @@
// Two signing paths: NIP-07 browser extension and NIP-46 remote signer
// (covers Primal app + Amber via bunker:// URI). The active signer is held
// in module state — there's only one logged-in user at a time.
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
export type SignerKind = "extension" | "bunker";
export type Signer = {
kind: SignerKind;
getPublicKey(): Promise<string>;
signEvent(template: EventTemplate): Promise<NostrEvent>;
};
let activeSigner: Signer | null = null;
export function getActiveSigner(): Signer | null {
return activeSigner;
}
export function clearSigner(): void {
activeSigner = null;
}
export async function loginWithExtension(): Promise<string> {
if (!window.nostr) {
throw new Error("No NIP-07 extension found. Try Alby, nos2x, or Primal extension.");
}
const ext = window.nostr;
const pubkey = await ext.getPublicKey();
activeSigner = {
kind: "extension",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await ext.signEvent({
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
})) as NostrEvent,
};
return pubkey;
}
export async function loginWithBunker(bunkerUri: string): Promise<string> {
// Lazy-load applesauce-signers — only pulled when bunker login is attempted
// so the dashboard works for users who only use NIP-07.
const mod = await import("applesauce-signers");
const Ctor =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
if (!Ctor) {
throw new Error("applesauce-signers does not expose NostrConnectSigner");
}
const fromUri = Ctor.fromBunkerURI ?? Ctor.fromURI;
if (!fromUri) {
throw new Error("NostrConnectSigner has no fromBunkerURI / fromURI helper");
}
const signer = await fromUri.call(Ctor, bunkerUri);
const pubkey: string = await signer.getPublicKey();
activeSigner = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
};
return pubkey;
}

View File

@@ -0,0 +1,60 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import * as api from "../services/api";
import * as signer from "../services/signer";
export const useAuthStore = defineStore("auth", () => {
const stored = api.loadToken();
const npub = ref<string | null>(stored?.npub ?? null);
const token = ref<string | null>(stored?.token ?? null);
const error = ref<string | null>(null);
const busy = ref(false);
const isLoggedIn = computed(() => !!token.value);
async function loginExtension(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithExtension();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Login failed";
throw e;
} finally {
busy.value = false;
}
}
async function loginBunker(uri: string): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithBunker(uri);
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Login failed";
throw e;
} finally {
busy.value = false;
}
}
function logout(): void {
api.clearToken();
signer.clearSigner();
token.value = null;
npub.value = null;
error.value = null;
}
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, logout };
});

View File

@@ -0,0 +1,41 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "../services/api";
import type { DatumSnapshot } from "../types";
const POLL_MS = 5000;
export const useStatsStore = defineStore("stats", () => {
const snapshot = ref<DatumSnapshot | null>(null);
const error = ref<string | null>(null);
const loading = ref(false);
let timer: number | null = null;
async function fetchOnce(): Promise<void> {
try {
loading.value = true;
const next = await api.fetchSnapshot();
snapshot.value = next;
error.value = null;
} catch (e) {
error.value = e instanceof Error ? e.message : "Fetch failed";
} finally {
loading.value = false;
}
}
function start(): void {
if (timer !== null) return;
void fetchOnce();
timer = window.setInterval(() => void fetchOnce(), POLL_MS);
}
function stop(): void {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
}
return { snapshot, error, loading, fetchOnce, start, stop };
});

67
apps/web/src/strings.ts Normal file
View File

@@ -0,0 +1,67 @@
// All user-facing copy. Lean into the futility — these are 4 hobby boards
// trying to hit a 1-in-quadrillions lottery. Take the piss with affection.
export const ROASTS: Record<string, string> = {
nerdqaxe: "wired together by a guy on Telegram. somehow still hashing.",
bitaxe: "single chip, single mum, one in 10²² odds. heart of gold.",
"avalon-nano-3": "USB-stick miner. AliExpress's revenge on the network hashrate.",
"avalon-mini-3": "the only one of you that pays for itself. don't get a big head.",
unknown: "we don't know what this is. we love it anyway.",
};
export const STATUS_LINES = {
hashing: "hashing",
stale: "thinking real hard",
idle: "having a lie down",
};
export const LOTTERY_LINES = [
(oddsPerDay: number) =>
`P(block today): ${formatPct(oddsPerDay)}. P(struck by lightning today): 0.000028%. hash on, brave little board.`,
(oddsPerDay: number) =>
`at this hashrate, a block is expected once every ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. compound those vibes.`,
() =>
`lifetime tickets purchased: many. winners: 0. hope: priceless.`,
(oddsPerDay: number) =>
`you're ${ratioVsLightning(oddsPerDay)}× as likely to find a block as get hit by lightning today. almost too easy.`,
() =>
`the network does not know you exist. and yet, you persist.`,
() =>
`every share is a ticket bought. nobody is buying with more conviction.`,
];
export const BLOCK_CELEBRATION_LINES = [
"WAIT. WHAT. CHECK THE LOG. YOU FOUND A — actually let me check that twice.",
"no but seriously, one of these little boards just won the lottery.",
"the network has been notified. so have your friends. so has god.",
];
export const STALE_LINES = [
"hasn't submitted in a while. either thinking really hard or the breaker tripped.",
"currently meditating. has been for some time.",
"not dead, just resting. probably.",
];
function formatPct(p: number): string {
if (!isFinite(p) || p <= 0) return "ε";
if (p >= 0.01) return `${(p * 100).toFixed(4)}%`;
return `${(p * 100).toExponential(2)}%`;
}
function humanYears(years: number): string {
if (!isFinite(years)) return "forever";
if (years < 1) return `${(years * 365).toFixed(1)} days`;
if (years < 100) return `${years.toFixed(1)} years`;
if (years < 1e6) return `${(years / 1e3).toFixed(1)} thousand years`;
if (years < 1e9) return `${(years / 1e6).toFixed(1)} million years`;
return `${(years / 1e9).toFixed(1)} billion years`;
}
function ratioVsLightning(oddsPerDay: number): string {
const lightning = 0.00000028;
const r = oddsPerDay / lightning;
if (r < 0.001) return r.toExponential(1);
if (r < 1) return r.toFixed(3);
if (r < 1000) return r.toFixed(1);
return r.toExponential(1);
}

172
apps/web/src/style.css Normal file
View File

@@ -0,0 +1,172 @@
/* gashboard cyberpunk theme */
:root {
--bg-0: #07090f;
--bg-1: #0c1120;
--bg-2: #131a2e;
--line: #1f2a4a;
--line-bright: #2c3a66;
--fg-0: #e6edff;
--fg-1: #b6c3e0;
--fg-2: #6a7aa0;
--neon-cyan: #29ffe6;
--neon-magenta: #ff3df0;
--neon-amber: #ffd84a;
--neon-red: #ff4f78;
--neon-green: #6cff8c;
--shadow-cyan: 0 0 16px rgba(41, 255, 230, 0.35);
--shadow-magenta: 0 0 16px rgba(255, 61, 240, 0.4);
--mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
background:
radial-gradient(1200px 600px at 20% -10%, rgba(255, 61, 240, 0.12), transparent),
radial-gradient(1000px 500px at 100% 110%, rgba(41, 255, 230, 0.10), transparent),
var(--bg-0);
color: var(--fg-0);
font-family: var(--mono);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
letter-spacing: 0.01em;
}
button {
font-family: var(--mono);
font-size: 13px;
background: transparent;
color: var(--fg-0);
border: 1px solid var(--line-bright);
padding: 8px 14px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: border-color 0.12s, color 0.12s, box-shadow 0.12s, background 0.12s;
}
button:hover:not(:disabled) {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
button.primary {
border-color: var(--neon-magenta);
color: var(--neon-magenta);
}
button.primary:hover:not(:disabled) {
background: rgba(255, 61, 240, 0.08);
box-shadow: var(--shadow-magenta);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input,
textarea {
font-family: var(--mono);
background: var(--bg-1);
color: var(--fg-0);
border: 1px solid var(--line-bright);
padding: 8px 10px;
width: 100%;
font-size: 13px;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
a {
color: var(--neon-cyan);
text-decoration: none;
}
.muted {
color: var(--fg-2);
}
.label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 11px;
color: var(--fg-2);
}
.panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent),
var(--bg-1);
border: 1px solid var(--line);
padding: 16px;
}
.glow-cyan {
color: var(--neon-cyan);
text-shadow: 0 0 8px rgba(41, 255, 230, 0.45);
}
.glow-magenta {
color: var(--neon-magenta);
text-shadow: 0 0 8px rgba(255, 61, 240, 0.5);
}
.glow-amber {
color: var(--neon-amber);
text-shadow: 0 0 8px rgba(255, 216, 74, 0.5);
}
.glow-red {
color: var(--neon-red);
text-shadow: 0 0 8px rgba(255, 79, 120, 0.5);
}
.glow-green {
color: var(--neon-green);
text-shadow: 0 0 8px rgba(108, 255, 140, 0.5);
}
/* CRT scanlines (very subtle, opt-in via body.crt class) */
body.crt::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background:
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0) 2px,
rgba(0, 0, 0, 0.18) 3px,
rgba(0, 0, 0, 0.18) 4px
);
mix-blend-mode: multiply;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.pulse {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
47%, 49% { opacity: 0.85; }
48% { opacity: 0.6; }
}
.flicker {
animation: flicker 5s steps(20) infinite;
}

87
apps/web/src/types.ts Normal file
View File

@@ -0,0 +1,87 @@
export type MinerStat = {
authUsername: string;
remoteHost: string;
nickname: string;
model: string;
location: string;
hashrateThs: number;
hashrateAgeS: number | null;
lastShareAgeS: number | null;
diffAcceptedSum: number;
diffAcceptedCount: number;
diffRejectedSum: number;
diffRejectedCount: number;
rejectPct: number;
vdiff: number;
userAgent: string;
subscribed: boolean;
status: "hashing" | "stale" | "idle";
};
export type PoolStat = {
combinedHashrateThs: number;
totalConnections: number;
totalSubscriptions: number;
activeThreads: number;
sharesAccepted: number;
sharesRejected: number;
uptimeSeconds: number;
connectionStatus: string;
poolHost: string;
poolTag: string;
minerTag: string;
poolDifficulty: number;
};
export type CurrentJob = {
blockHeight: number;
blockValueSats: number;
difficulty: number;
bits: string;
prevBlock: string;
target: string;
version: string;
weight: number;
size: number;
txnCount: number;
timeInfo: string;
};
export type DatumSnapshot = {
ok: boolean;
fetchedAt: number;
pool: PoolStat;
job: CurrentJob;
miners: MinerStat[];
error?: { code: string; message: string };
};
export type LoginResponse = {
token: string;
npub: string;
expiresAt: number;
};
export type ApiError = { error: { code: string; message: string } };
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
}): Promise<{
id: string;
pubkey: string;
kind: number;
created_at: number;
tags: string[][];
content: string;
sig: string;
}>;
};
}
}

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>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
const bunkerUri = ref("");
const showBunkerInput = ref(false);
async function loginExt(): Promise<void> {
try {
await auth.loginExtension();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
}
}
async function loginBunker(): Promise<void> {
if (!bunkerUri.value.trim().startsWith("bunker://")) {
auth.error = 'URI must start with "bunker://"';
return;
}
try {
await auth.loginBunker(bunkerUri.value.trim());
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
}
}
</script>
<template>
<div class="login">
<div class="panel">
<div class="header">
<div class="title glow-cyan">// auth_required</div>
<div class="subtitle muted">
gashboard is a closed dashboard. only allow-listed npubs may pass.
</div>
</div>
<div class="options">
<button class="primary big" :disabled="auth.busy" @click="loginExt">
{{ auth.busy ? "..." : "browser extension" }}
<small>NIP-07 · alby, nos2x, primal</small>
</button>
<button
class="big"
:disabled="auth.busy"
@click="showBunkerInput = !showBunkerInput"
>
remote signer
<small>NIP-46 · primal app, amber, nsecbunker</small>
</button>
<div v-if="showBunkerInput" class="bunker">
<label class="label">paste bunker URI</label>
<input
v-model="bunkerUri"
placeholder="bunker://<pubkey>?relay=wss://…&secret=…"
spellcheck="false"
autocomplete="off"
/>
<button class="primary" :disabled="auth.busy" @click="loginBunker">
{{ auth.busy ? "connecting" : "connect" }}
</button>
</div>
</div>
<div v-if="auth.error" class="err">
{{ auth.error }}
</div>
<div class="foot muted">
no extension? install
<a href="https://github.com/nostr-protocol/nips/blob/master/07.md" target="_blank" rel="noopener">alby</a>,
or use a remote signer.
</div>
</div>
</div>
</template>
<style scoped>
.login {
width: min(540px, 92vw);
}
.header { margin-bottom: 24px; }
.title {
font-size: 22px;
letter-spacing: 0.18em;
margin-bottom: 6px;
font-weight: 700;
}
.subtitle { font-size: 12px; }
.options { display: flex; flex-direction: column; gap: 14px; }
button.big {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 14px 16px;
font-size: 14px;
}
button.big small {
font-size: 10.5px;
font-weight: 400;
text-transform: none;
color: var(--fg-2);
letter-spacing: 0.04em;
}
.bunker {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px dashed var(--line-bright);
background: rgba(0, 0, 0, 0.25);
}
.err {
margin-top: 18px;
padding: 10px 12px;
border: 1px solid var(--neon-red);
color: var(--neon-red);
font-size: 12px;
}
.foot {
margin-top: 24px;
font-size: 11px;
letter-spacing: 0.04em;
}
</style>