Add PWA support and miner race

This commit is contained in:
Dorian
2026-05-06 19:21:06 +01:00
parent 360b1ebe66
commit 0c8e26f597
21 changed files with 573 additions and 16 deletions

View File

@@ -28,6 +28,8 @@ export function buildApp() {
"img-src": ["'self'", "data:"],
"connect-src": ["'self'", "wss://relay.primal.net"],
"font-src": ["'self'", "data:"],
"manifest-src": ["'self'"],
"worker-src": ["'self'"],
"frame-ancestors": ["'none'"],
"upgrade-insecure-requests": null,
},
@@ -67,10 +69,21 @@ export function buildApp() {
if (staticDir && fs.existsSync(staticDir)) {
logger.info({ staticDir }, "serving web assets");
app.use(express.static(staticDir, { index: false, maxAge: "1h" }));
app.use(
express.static(staticDir, {
index: false,
maxAge: "1h",
setHeaders: (res, filePath) => {
if (filePath.endsWith("sw.js") || filePath.endsWith("manifest.webmanifest")) {
res.setHeader("Cache-Control", "no-cache");
}
},
}),
);
app.get(/.*/, (_req, res, next) => {
const indexFile = path.join(staticDir, "index.html");
if (!fs.existsSync(indexFile)) return next();
res.setHeader("Cache-Control", "no-cache");
res.sendFile(indexFile);
});
} else {

View File

@@ -4,6 +4,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0e1a" />
<meta name="application-name" content="gashboard" />
<meta name="apple-mobile-web-app-title" content="gashboard" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/icon.svg" />
<title>gashboard</title>
</head>
<body>

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="gashboard">
<rect width="512" height="512" fill="#07090f"/>
<rect x="34" y="34" width="444" height="444" fill="#0c1120" stroke="#29ffe6" stroke-width="12"/>
<path d="M98 314h68v74H98zm92-118h68v192h-68zm92 54h68v138h-68zm92-126h68v264h-68z" fill="#ff3df0"/>
<path d="M80 256h64l34-82 62 178 58-132 44 70h90" fill="none" stroke="#29ffe6" stroke-width="18" stroke-linejoin="round" stroke-linecap="round"/>
<text x="256" y="104" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="54" font-weight="700">G</text>
</svg>

After

Width:  |  Height:  |  Size: 630 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="gashboard">
<rect width="512" height="512" fill="#07090f"/>
<circle cx="256" cy="256" r="188" fill="#0c1120" stroke="#29ffe6" stroke-width="14"/>
<path d="M126 316h62v72h-62zm84-118h62v190h-62zm84 52h62v138h-62zm84-126h62v264h-62z" fill="#ff3df0"/>
<path d="M104 258h66l32-82 60 176 58-132 44 70h54" fill="none" stroke="#29ffe6" stroke-width="18" stroke-linejoin="round" stroke-linecap="round"/>
<text x="256" y="112" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="52" font-weight="700">G</text>
</svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@@ -0,0 +1,34 @@
{
"name": "gashboard",
"short_name": "gashboard",
"description": "Solo mining dashboard for Datum miners.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#07090f",
"theme_color": "#0a0e1a",
"categories": ["utilities", "finance"],
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/maskable.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Graphs",
"short_name": "Graphs",
"url": "/graphs",
"description": "Open telemetry graphs"
}
]
}

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 220" role="img" aria-label="Avalon Mini 3 miner">
<rect width="420" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="44" y="56" width="332" height="110" rx="16" fill="#1f2937" stroke="#ff3df0" stroke-width="4"/>
<rect x="70" y="78" width="210" height="66" rx="8" fill="#0b0f19" stroke="#29ffe6" stroke-width="3"/>
<path d="M92 96h166M92 112h166M92 128h166" stroke="#475569" stroke-width="7" stroke-linecap="round"/>
<circle cx="326" cy="111" r="34" fill="#07090f" stroke="#ffd84a" stroke-width="5"/>
<path d="M326 78v66M293 111h66M303 88l46 46M349 88l-46 46" stroke="#6cff8c" stroke-width="4"/>
<path d="M76 166h268M104 184h212" stroke="#475569" stroke-width="8" stroke-linecap="round"/>
<circle cx="62" cy="74" r="6" fill="#29ffe6"/>
<circle cx="360" cy="74" r="6" fill="#ff3df0"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="9" stdDeviation="9" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 1023 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Avalon Nano 3 miner">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="76" y="44" width="168" height="128" rx="20" fill="#e7edf5" stroke="#29ffe6" stroke-width="4"/>
<rect x="96" y="64" width="128" height="88" rx="14" fill="#111827"/>
<circle cx="160" cy="108" r="42" fill="#07090f" stroke="#ff3df0" stroke-width="6"/>
<path d="M160 68c16 22 16 58 0 80M120 108c22-16 58-16 80 0M132 80c30 4 52 26 56 56M188 80c-4 30-26 52-56 56" stroke="#29ffe6" stroke-width="4" stroke-linecap="round"/>
<rect x="112" y="166" width="96" height="12" rx="6" fill="#475569"/>
<path d="M64 112h28M228 112h28" stroke="#ffd84a" stroke-width="8" stroke-linecap="round"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".45"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 925 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Bitaxe miner">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="72" y="70" width="176" height="84" rx="12" fill="#152033" stroke="#6cff8c" stroke-width="4"/>
<rect x="94" y="88" width="56" height="48" rx="6" fill="#0b0f19" stroke="#29ffe6" stroke-width="3"/>
<circle cx="122" cy="112" r="18" fill="#07090f" stroke="#ff3df0" stroke-width="5"/>
<path d="M122 94v36M104 112h36" stroke="#ffd84a" stroke-width="3"/>
<rect x="166" y="90" width="54" height="18" rx="3" fill="#29ffe6" opacity=".85"/>
<rect x="166" y="118" width="38" height="10" rx="3" fill="#ff3df0" opacity=".85"/>
<path d="M86 154h148M104 170h112" stroke="#475569" stroke-width="7" stroke-linecap="round"/>
<circle cx="80" cy="80" r="5" fill="#ffd84a"/>
<circle cx="240" cy="80" r="5" fill="#ffd84a"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Boomer Heater">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="72" y="48" width="176" height="132" rx="14" fill="#3b2f2f" stroke="#ff4f78" stroke-width="4"/>
<rect x="92" y="70" width="136" height="84" rx="8" fill="#1f1717"/>
<path d="M116 142c-18-28 18-36 0-64M160 142c-18-28 18-36 0-64M204 142c-18-28 18-36 0-64" fill="none" stroke="#ff8a4f" stroke-width="8" stroke-linecap="round"/>
<path d="M90 180h140" stroke="#475569" stroke-width="10" stroke-linecap="round"/>
<circle cx="224" cy="58" r="9" fill="#6a7aa0"/>
<text x="160" y="205" text-anchor="middle" fill="#6a7aa0" font-family="monospace" font-size="15" font-weight="700">NO HASHES. JUST HEAT.</text>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 940 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="NerdQAxe miner">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="58" y="54" width="204" height="112" rx="10" fill="#111827" stroke="#29ffe6" stroke-width="4"/>
<rect x="78" y="74" width="74" height="70" rx="6" fill="#2a102b" stroke="#ff3df0" stroke-width="3"/>
<circle cx="115" cy="109" r="28" fill="#07090f" stroke="#29ffe6" stroke-width="5"/>
<path d="M115 82v54M88 109h54M96 90l38 38M134 90l-38 38" stroke="#6cff8c" stroke-width="3"/>
<rect x="170" y="76" width="70" height="28" rx="4" fill="#05070c" stroke="#ffd84a" stroke-width="3"/>
<path d="M180 92h50" stroke="#ffd84a" stroke-width="4"/>
<path d="M72 166h176M88 184h144" stroke="#475569" stroke-width="8" stroke-linecap="round"/>
<circle cx="81" cy="58" r="8" fill="#ff3df0"/>
<circle cx="239" cy="58" r="8" fill="#29ffe6"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

74
apps/web/public/sw.js Normal file
View File

@@ -0,0 +1,74 @@
const CACHE_VERSION = "gashboard-pwa-v1";
const APP_SHELL = ["/", "/manifest.webmanifest", "/icons/icon.svg", "/icons/maskable.svg"];
const MINER_ASSETS = [
"/miners/nerdqaxe.svg",
"/miners/bitaxe.svg",
"/miners/avalon-nano-3.svg",
"/miners/avalon-mini-3.svg",
"/miners/boomer-heater.svg",
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_VERSION).then((cache) => cache.addAll([...APP_SHELL, ...MINER_ASSETS])));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") return;
if (req.mode === "navigate") {
event.respondWith(networkFirst(req, "/"));
return;
}
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/icons/")) {
event.respondWith(cacheFirst(req));
return;
}
event.respondWith(networkFirst(req));
});
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
async function cacheFirst(req) {
const cache = await caches.open(CACHE_VERSION);
const cached = await cache.match(req);
if (cached) return cached;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
}
async function networkFirst(req, fallbackPath) {
const cache = await caches.open(CACHE_VERSION);
try {
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
} catch {
const cached = await cache.match(req);
if (cached) return cached;
if (fallbackPath) {
const fallback = await cache.match(fallbackPath);
if (fallback) return fallback;
}
throw new Error("offline");
}
}

View File

@@ -1,11 +1,20 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useStatsStore } from "./stores/stats";
const auth = useAuthStore();
const stats = useStatsStore();
const route = useRoute();
const crt = ref(false);
const heatLevel = computed(() => {
const snap = stats.snapshot;
if (!snap?.miners.length) return 0;
const expected = snap.miners.reduce((sum, m) => sum + m.expectedHashrateThs, 0);
if (expected <= 0) return 0;
return Math.max(0, Math.min(1, snap.pool.combinedHashrateThs / expected));
});
onMounted(() => {
const stored = localStorage.getItem("gashboard.crt") === "1";
@@ -22,6 +31,16 @@ function toggleCrt(): void {
function shortNpub(n: string): string {
return n.length > 16 ? `${n.slice(0, 8)}${n.slice(-4)}` : n;
}
watch(
heatLevel,
(level) => {
document.documentElement.style.setProperty("--heat", level.toFixed(3));
document.documentElement.style.setProperty("--heat-color", level > 0.75 ? "#ff4f78" : "#ff3df0");
document.body.classList.toggle("hot-hash", level > 0.75);
},
{ immediate: true },
);
</script>
<template>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
const rows = computed(() => {
const miners = props.snapshot?.miners ?? [];
const max = Math.max(...miners.map((m) => m.hashrateThs), 0.001);
const realRows = miners
.map((miner) => {
const key = minerKey(miner);
const samples = props.history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
const velocity = samples.length >= 2 ? samples.at(-1)! - samples[0]! : 0;
return {
key,
miner,
image: imageFor(miner),
width: Math.max(8, (miner.hashrateThs / max) * 100),
trend: velocity,
place: 0,
};
})
.sort((a, b) => b.miner.hashrateThs - a.miner.hashrateThs)
.map((row, idx) => ({ ...row, place: idx + 1, joke: "" }));
return [
...realRows,
{
key: "boomer-heater",
miner: {
nickname: "Boomer Heater",
model: "Legacy Resistance Heater",
hashrateThs: 0,
status: "idle",
},
image: "/miners/boomer-heater.svg",
width: 0,
trend: 0,
place: realRows.length + 1,
joke: "0 TH/s · 0 shares · 0 sats · just vibes and a pension.",
},
];
});
function minerKey(m: MinerStat): string {
return m.nickname || m.authUsername || m.remoteHost;
}
function imageFor(m: MinerStat): string {
if (m.nickname === "QU4CK") return "/miners/nerdqaxe.svg";
if (m.nickname === "P1XEL") return "/miners/bitaxe.svg";
if (m.nickname === "N4N0") return "/miners/avalon-nano-3.svg";
return "/miners/avalon-mini-3.svg";
}
</script>
<template>
<section class="race panel">
<header>
<div>
<div class="label">hashrate race</div>
<h2 class="glow-cyan">Tiny machines, deeply unfair track</h2>
</div>
<span class="muted">ranked by current TH/s</span>
</header>
<div class="track">
<div v-for="row in rows" :key="row.key" class="lane">
<div class="rank">#{{ row.place }}</div>
<img :src="row.image" :alt="row.miner.model" />
<div class="runner">
<div class="meta">
<strong>{{ row.miner.nickname }}</strong>
<span>{{ row.joke || `${row.miner.hashrateThs.toFixed(2)} TH/s` }}</span>
</div>
<div :class="['bar', { dead: row.width <= 0 }]">
<span :style="{ width: `${row.width}%` }">
<i>{{ row.width <= 0 ? "lol" : row.trend >= 0 ? "▲" : "▼" }}</i>
</span>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.race {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: start;
}
h2 {
margin: 2px 0 0;
font-size: 20px;
letter-spacing: 0;
}
.track {
display: flex;
flex-direction: column;
gap: 12px;
}
.lane {
display: grid;
grid-template-columns: 46px 86px 1fr;
gap: 12px;
align-items: center;
}
.rank {
color: var(--neon-amber);
font-weight: 700;
font-size: 18px;
}
img {
width: 86px;
height: 58px;
object-fit: contain;
}
.runner {
min-width: 0;
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
}
.meta span {
color: var(--fg-2);
}
.bar {
height: 18px;
margin-top: 5px;
border: 1px solid var(--line);
background: var(--bg-2);
overflow: hidden;
}
.bar span {
display: flex;
justify-content: flex-end;
align-items: center;
min-width: 28px;
height: 100%;
background: linear-gradient(90deg, var(--neon-cyan), var(--neon-magenta), var(--heat-color, var(--neon-red)));
box-shadow: 0 0 14px rgba(255, 79, 120, 0.25);
transition: width 0.6s ease;
}
.bar.dead span {
width: 22px !important;
background: #475569;
box-shadow: none;
}
.bar i {
padding-right: 6px;
color: #07090f;
font-style: normal;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 620px) {
header {
flex-direction: column;
}
.lane {
grid-template-columns: 36px 64px 1fr;
}
img {
width: 64px;
}
}
</style>

View File

@@ -2,9 +2,11 @@ import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";
import { registerPwa } from "./services/pwa";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
registerPwa();

View File

@@ -0,0 +1,18 @@
export function registerPwa(): void {
if (!("serviceWorker" in navigator)) return;
if (import.meta.env.DEV) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").then((registration) => {
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
worker.postMessage({ type: "SKIP_WAITING" });
}
});
});
});
});
}

View File

@@ -17,6 +17,7 @@ export type Signer = {
let activeSigner: Signer | null = null;
const NOSTR_CONNECT_RELAYS = ["wss://relay.primal.net"];
const NOSTR_CONNECT_TIMEOUT_MS = 120_000;
const NOSTR_CONNECT_PENDING_KEY = "gashboard.nostrconnect.pending";
const pool = new SimplePool();
export function getActiveSigner(): Signer | null {
@@ -73,6 +74,18 @@ export async function loginWithBunker(bunkerUri: string): Promise<string> {
}
export async function loginWithRemoteApp(): Promise<string> {
return connectRemoteApp({ openApp: true });
}
export async function resumeRemoteAppLogin(): Promise<string> {
return connectRemoteApp({ openApp: false });
}
export function hasPendingRemoteAppLogin(): boolean {
return !!sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
}
async function connectRemoteApp(opts: { openApp: boolean }): Promise<string> {
const mod = await import("applesauce-signers");
const NostrConnectSigner =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
@@ -84,24 +97,38 @@ export async function loginWithRemoteApp(): Promise<string> {
throw new Error("Nostr Connect signer support is unavailable");
}
const pending = loadPendingRemoteLogin();
const clientSigner = pending
? new PrivateKeySigner(new Uint8Array(pending.key))
: new PrivateKeySigner();
const signer = new NostrConnectSigner({
relays: NOSTR_CONNECT_RELAYS,
signer: new PrivateKeySigner(),
relays: pending?.relays ?? NOSTR_CONNECT_RELAYS,
signer: clientSigner,
...(pending ? { secret: pending.secret } : {}),
subscriptionMethod: subscribeToRelays,
publishMethod: publishToRelays,
});
const permissions =
NostrConnectSigner.buildSigningPermissions?.([27235]) ?? ["sign_event:27235"];
const uri = withCallback(
signer.getNostrConnectURI({
name: "gashboard",
url: window.location.origin,
permissions,
}),
);
if (!pending) {
savePendingRemoteLogin({
key: Array.from(clientSigner.key),
secret: signer.secret,
relays: NOSTR_CONNECT_RELAYS,
});
}
openSignerApp(uri);
if (opts.openApp) {
const uri = withCallback(
signer.getNostrConnectURI({
name: "gashboard",
url: window.location.origin,
permissions,
}),
);
openSignerApp(uri);
}
const abort = new AbortController();
const timeout = window.setTimeout(() => abort.abort(), NOSTR_CONNECT_TIMEOUT_MS);
@@ -115,6 +142,7 @@ export async function loginWithRemoteApp(): Promise<string> {
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
};
clearPendingRemoteLogin();
return pubkey;
} finally {
window.clearTimeout(timeout);
@@ -122,6 +150,32 @@ export async function loginWithRemoteApp(): Promise<string> {
}
}
type PendingRemoteLogin = {
key: number[];
secret: string;
relays: string[];
};
function loadPendingRemoteLogin(): PendingRemoteLogin | null {
try {
const raw = sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as PendingRemoteLogin;
if (!Array.isArray(parsed.key) || !parsed.secret || !Array.isArray(parsed.relays)) return null;
return parsed;
} catch {
return null;
}
}
function savePendingRemoteLogin(pending: PendingRemoteLogin): void {
sessionStorage.setItem(NOSTR_CONNECT_PENDING_KEY, JSON.stringify(pending));
}
function clearPendingRemoteLogin(): void {
sessionStorage.removeItem(NOSTR_CONNECT_PENDING_KEY);
}
function withCallback(uri: string): string {
const separator = uri.includes("?") ? "&" : "?";
return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`;

View File

@@ -66,6 +66,24 @@ export const useAuthStore = defineStore("auth", () => {
}
}
async function resumeRemoteAppLogin(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.resumeRemoteAppLogin();
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 : "Remote signer login failed";
throw e;
} finally {
busy.value = false;
}
}
function logout(): void {
api.clearToken();
signer.clearSigner();
@@ -74,5 +92,17 @@ export const useAuthStore = defineStore("auth", () => {
error.value = null;
}
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, loginRemoteApp, logout };
return {
npub,
token,
error,
busy,
isLoggedIn,
loginExtension,
loginBunker,
loginRemoteApp,
resumeRemoteAppLogin,
hasPendingRemoteAppLogin: signer.hasPendingRemoteAppLogin,
logout,
};
});

View File

@@ -33,6 +33,7 @@ body,
body {
background:
radial-gradient(900px 460px at 50% -20%, rgba(255, 79, 120, calc(var(--heat, 0) * 0.22)), transparent),
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);
@@ -44,6 +45,12 @@ body {
letter-spacing: 0.01em;
}
body.hot-hash {
--line-bright: #7a3046;
--neon-magenta: #ff4f78;
--shadow-magenta: 0 0 18px rgba(255, 79, 120, 0.48);
}
button {
font-family: var(--mono);
font-size: 13px;

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
import { useStatsStore } from "../stores/stats";
import type { HistoryPoint } from "../types";
import Shameboard from "../components/Shameboard.vue";
import MinerRace from "../components/MinerRace.vue";
const stats = useStatsStore();
@@ -167,6 +168,8 @@ function fmt(n: number, digits = 2): string {
</div>
</header>
<MinerRace :snapshot="stats.snapshot" :history="stats.history" />
<div class="metric-grid">
<div class="metric panel">
<div class="label">now</div>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const bunkerUri = ref("");
const showBunkerInput = ref(false);
const waitingRemote = ref(false);
@@ -42,6 +43,23 @@ async function loginRemoteApp(): Promise<void> {
waitingRemote.value = false;
}
}
async function resumeRemoteApp(): Promise<void> {
if (!auth.hasPendingRemoteAppLogin()) return;
waitingRemote.value = true;
try {
await auth.resumeRemoteAppLogin();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
} finally {
waitingRemote.value = false;
}
}
onMounted(() => {
if (route.query.remote === "return") void resumeRemoteApp();
});
</script>
<template>

View File

@@ -1,7 +1,24 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
onMounted(() => {
if (auth.isLoggedIn) {
void router.replace({ name: "dashboard" });
return;
}
void router.replace({ name: "login", query: { remote: "return" } });
});
</script>
<template>
<div class="callback panel">
<div class="title glow-cyan">// signer_return</div>
<p class="muted">Return to the gashboard tab to finish sign-in.</p>
<p class="muted">Finishing remote signer login...</p>
</div>
</template>