diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..058a08f --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/apps/web/src/components/BlockCelebration.vue b/apps/web/src/components/BlockCelebration.vue new file mode 100644 index 0000000..5cb453a --- /dev/null +++ b/apps/web/src/components/BlockCelebration.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/apps/web/src/components/LotteryWidget.vue b/apps/web/src/components/LotteryWidget.vue new file mode 100644 index 0000000..70f9301 --- /dev/null +++ b/apps/web/src/components/LotteryWidget.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/apps/web/src/components/MinerCard.vue b/apps/web/src/components/MinerCard.vue new file mode 100644 index 0000000..e57ac66 --- /dev/null +++ b/apps/web/src/components/MinerCard.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/apps/web/src/components/PoolHero.vue b/apps/web/src/components/PoolHero.vue new file mode 100644 index 0000000..93a69bb --- /dev/null +++ b/apps/web/src/components/PoolHero.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/apps/web/src/components/ShareTicker.vue b/apps/web/src/components/ShareTicker.vue new file mode 100644 index 0000000..6a0b419 --- /dev/null +++ b/apps/web/src/components/ShareTicker.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..0a00efb --- /dev/null +++ b/apps/web/src/main.ts @@ -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"); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts new file mode 100644 index 0000000..bd0b9fc --- /dev/null +++ b/apps/web/src/router.ts @@ -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; +}); diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts new file mode 100644 index 0000000..0bb5c66 --- /dev/null +++ b/apps/web/src/services/api.ts @@ -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 { + 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 { + 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(path: string): Promise { + 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 { + return authedGet("/api/datum/stats"); +} diff --git a/apps/web/src/services/nip98.ts b/apps/web/src/services/nip98.ts new file mode 100644 index 0000000..6f2e3d4 --- /dev/null +++ b/apps/web/src/services/nip98.ts @@ -0,0 +1,21 @@ +import type { EventTemplate } from "nostr-tools"; +import { getActiveSigner } from "./signer"; + +export async function buildNip98AuthHeader(method: string, fullUrl: string): Promise { + 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}`; +} diff --git a/apps/web/src/services/signer.ts b/apps/web/src/services/signer.ts new file mode 100644 index 0000000..523c5a3 --- /dev/null +++ b/apps/web/src/services/signer.ts @@ -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; + signEvent(template: EventTemplate): Promise; +}; + +let activeSigner: Signer | null = null; + +export function getActiveSigner(): Signer | null { + return activeSigner; +} + +export function clearSigner(): void { + activeSigner = null; +} + +export async function loginWithExtension(): Promise { + 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 { + // 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; +} diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts new file mode 100644 index 0000000..0529b12 --- /dev/null +++ b/apps/web/src/stores/auth.ts @@ -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(stored?.npub ?? null); + const token = ref(stored?.token ?? null); + const error = ref(null); + const busy = ref(false); + + const isLoggedIn = computed(() => !!token.value); + + async function loginExtension(): Promise { + 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 { + 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 }; +}); diff --git a/apps/web/src/stores/stats.ts b/apps/web/src/stores/stats.ts new file mode 100644 index 0000000..49ba888 --- /dev/null +++ b/apps/web/src/stores/stats.ts @@ -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(null); + const error = ref(null); + const loading = ref(false); + let timer: number | null = null; + + async function fetchOnce(): Promise { + 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 }; +}); diff --git a/apps/web/src/strings.ts b/apps/web/src/strings.ts new file mode 100644 index 0000000..c8140da --- /dev/null +++ b/apps/web/src/strings.ts @@ -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 = { + 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); +} diff --git a/apps/web/src/style.css b/apps/web/src/style.css new file mode 100644 index 0000000..c0b352c --- /dev/null +++ b/apps/web/src/style.css @@ -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; +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts new file mode 100644 index 0000000..3101de0 --- /dev/null +++ b/apps/web/src/types.ts @@ -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; + 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; + }>; + }; + } +} diff --git a/apps/web/src/views/DashboardView.vue b/apps/web/src/views/DashboardView.vue new file mode 100644 index 0000000..1100288 --- /dev/null +++ b/apps/web/src/views/DashboardView.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue new file mode 100644 index 0000000..9b3c73d --- /dev/null +++ b/apps/web/src/views/LoginView.vue @@ -0,0 +1,134 @@ + + + + +