feat(apps): add Gashboard with native signing and viewer access
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { useStatsStore } from "./stores/stats";
|
||||
import ChatDrawer from "./components/ChatDrawer.vue";
|
||||
import ReleaseNotesDrawer from "./components/ReleaseNotesDrawer.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const stats = useStatsStore();
|
||||
const route = useRoute();
|
||||
const crt = ref(false);
|
||||
const chatOpen = ref(false);
|
||||
const releaseOpen = ref(false);
|
||||
const LOW_HASHRATE_THS = 10;
|
||||
const TOP_HASHRATE_THS = 70;
|
||||
const heatLevel = computed(() => {
|
||||
const total = stats.snapshot?.pool.combinedHashrateThs ?? 0;
|
||||
return Math.max(0, Math.min(1, (total - LOW_HASHRATE_THS) / (TOP_HASHRATE_THS - LOW_HASHRATE_THS)));
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
<header class="topbar">
|
||||
<div class="brand glow-magenta flicker">
|
||||
⌬ GASHBOARD
|
||||
<span class="muted">// solo lottery dashboard</span>
|
||||
</div>
|
||||
<nav v-if="auth.isLoggedIn" class="nav">
|
||||
<RouterLink to="/">status</RouterLink>
|
||||
<RouterLink to="/graphs">graphs</RouterLink>
|
||||
<RouterLink to="/access">access</RouterLink>
|
||||
</nav>
|
||||
<div class="actions">
|
||||
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
|
||||
<button class="thin" @click="releaseOpen = !releaseOpen">notes</button>
|
||||
<template v-if="auth.isLoggedIn">
|
||||
<button class="thin" @click="chatOpen = !chatOpen">chat</button>
|
||||
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
|
||||
<button class="thin" @click="auth.logout()">logout</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav v-if="auth.isLoggedIn" class="mobile-tabbar" aria-label="mobile navigation">
|
||||
<RouterLink to="/">status</RouterLink>
|
||||
<RouterLink to="/graphs">graphs</RouterLink>
|
||||
<RouterLink to="/access">access</RouterLink>
|
||||
<button type="button" @click="releaseOpen = !releaseOpen">notes</button>
|
||||
<button type="button" @click="chatOpen = !chatOpen">chat</button>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
|
||||
<ChatDrawer v-if="auth.isLoggedIn" :open="chatOpen" @close="chatOpen = false" />
|
||||
<ReleaseNotesDrawer :open="releaseOpen" @close="releaseOpen = false" />
|
||||
</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;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.nav a {
|
||||
color: var(--fg-1);
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.nav a:hover,
|
||||
.nav a.router-link-active {
|
||||
border-color: var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
box-shadow: var(--shadow-cyan);
|
||||
}
|
||||
.mobile-tabbar {
|
||||
display: none;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.brand .muted,
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
.actions {
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.actions button:nth-of-type(n + 2) {
|
||||
display: none;
|
||||
}
|
||||
.actions .muted {
|
||||
display: none;
|
||||
}
|
||||
.mobile-tabbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 12;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(7, 9, 15, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.mobile-tabbar a,
|
||||
.mobile-tabbar button {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
color: var(--fg-1);
|
||||
padding: 10px 4px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: transparent;
|
||||
}
|
||||
.mobile-tabbar a:last-child,
|
||||
.mobile-tabbar button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.mobile-tabbar a.router-link-active,
|
||||
.mobile-tabbar button:hover {
|
||||
color: var(--neon-cyan);
|
||||
box-shadow: inset 0 -1px 0 var(--neon-cyan), var(--shadow-cyan);
|
||||
}
|
||||
main {
|
||||
padding: 16px 16px calc(76px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.footbar {
|
||||
padding-bottom: calc(76px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
export type ChatSticker = {
|
||||
label: string;
|
||||
text: string;
|
||||
kind?: "sticker" | "gif";
|
||||
image?: string;
|
||||
};
|
||||
|
||||
export const STICKER_PREFIX = "[gashboard sticker]";
|
||||
const GIF_MARKER = "[gif]";
|
||||
const IMAGE_MARKER = "[img:";
|
||||
|
||||
export const CHAT_STICKERS: ChatSticker[] = [
|
||||
{ label: "fiat heater", text: "FIAT HEATER DETECTED: warm room, cold wallet." },
|
||||
{ label: "no hashes", text: "NO HASHES. JUST GHEI. just fiat and a pension." },
|
||||
{ label: "cope block", text: "BLOCK FOUND: not by us, obviously. carry on pretending." },
|
||||
{ label: "desk miner", text: "DESK MINER ENERGY: tiny fan, enormous delusion." },
|
||||
{ label: "pool bully", text: "BIG POOL ENTERED CHAT: scaled up to not shame the children." },
|
||||
{ label: "paper hands", text: "PAPER HANDS, PLASTIC HEATER, ZERO SATS." },
|
||||
{ label: "nonce cult", text: "NONCE CULT MEETING: attendance low, conviction insane." },
|
||||
{ label: "fiat friend", text: "YOUR FIAT FRIEND THINKS THE HEATER IS AN INVESTMENT." },
|
||||
{ label: "warm tonight", text: "SOMEONE'S GETTING WARMED TONIGHT. maybe not paid." },
|
||||
{ label: "difficulty", text: "DIFFICULTY ADJUSTMENT: reality filed another complaint." },
|
||||
{ label: "best calc", text: "BEST CALC: almost important, spiritually devastating." },
|
||||
{ label: "hash high", text: "HASHRATE UP. ODDS STILL NEED A TELESCOPE." },
|
||||
{ label: "pension", text: "JUST FIAT AND A PENSION: the saddest proof-of-warmth." },
|
||||
{ label: "sovereign", text: "SOVEREIGN MINER: no block, no boss, no apology." },
|
||||
{ label: "amber", text: "SIGNED BY A REAL SIGNER, UNLIKE YOUR BOOMER'S DIRECT DEBIT." },
|
||||
{ label: "mempool", text: "MEMPOOL SAYS NO. DASHBOARD SAYS LOL." },
|
||||
{ label: "reject", text: "REJECTED SHARE: even the server had standards." },
|
||||
{ label: "bigpapa", text: "BIGPAPA IS WATCHING. probably collecting fuck all." },
|
||||
{ label: "lottery", text: "SOLO LOTTERY TICKET PURCHASED WITH ELECTRICITY AND DENIAL." },
|
||||
{ label: "fiat people", text: "FIAT PEOPLE SEE A HEATER. MINERS SEE A COWARD." },
|
||||
{ label: "gif cope", kind: "gif", text: "LIVE FOOTAGE OF ME PRETENDING THE ODDS ARE FINE." },
|
||||
{ label: "gif panic", kind: "gif", text: "THE HASHRATE MOVED 0.01 TH/S AND I HAVE BECOME UNBEARABLE." },
|
||||
{ label: "gif refresh", kind: "gif", text: "REFRESHING THE DASHBOARD LIKE IT OWES ME MONEY." },
|
||||
{ label: "gif chart", kind: "gif", text: "GRAPH GOES UP. BRAIN GOES OFF." },
|
||||
{ label: "gif no block", kind: "gif", text: "NO BLOCK? MUST BE A CONSPIRACY AGAINST MY USB PORT." },
|
||||
{ label: "gif office", kind: "gif", text: "CORPORATE NEEDS YOU TO FIND THE DIFFERENCE: HEATER / MINER." },
|
||||
{ label: "gif maths", kind: "gif", text: "ME EXPLAINING 1 IN 2 BILLION LIKE IT IS A PLAN." },
|
||||
{ label: "gif deploy", kind: "gif", text: "DEPLOYED TO PRODUCTION. MAY GOD REVIEW THE DIFF." },
|
||||
{ label: "gif smug", kind: "gif", text: "I MINE SOLO, WHICH IS HOW YOU KNOW I AM DIFFICULT AT DINNER." },
|
||||
{ label: "gif invoice", kind: "gif", text: "ELECTRIC BILL ARRIVED. IT IS NOW A WHITEPAPER." },
|
||||
{ label: "ngmi heater", text: "NGMI: NOT GENERATING MINING INCOME." },
|
||||
{ label: "printer money", text: "FIAT PRINTER WENT BRRR. MINER WENT PLEASE NOTICE ME." },
|
||||
{ label: "normie mode", text: "NORMIE MODE: PAYING FOR HEAT WITHOUT EVEN ATTEMPTING A BLOCK." },
|
||||
{ label: "bullish fan", text: "LOUD FAN, QUIET BALANCE, MAXIMUM CONVICTION." },
|
||||
{ label: "chart crime", text: "THIS CHART IS A CRIME SCENE WITH AXES." },
|
||||
{ label: "share cope", text: "ACCEPTED SHARE: A PARTICIPATION TROPHY WITH VOLTAGE." },
|
||||
{ label: "stack sats", text: "STACKING SATS? NO. STACKING RECEIPTS FROM THE POWER COMPANY." },
|
||||
{ label: "main character", text: "MAIN CHARACTER ENERGY, BACKGROUND HASHRATE." },
|
||||
{ label: "block soon", text: "BLOCK SOON. SOURCE: THE SAME PART OF MY BRAIN THAT BUYS TOPS." },
|
||||
{ label: "heater max", text: "HEATER ON MAX. HASHES ON VACATION." },
|
||||
{
|
||||
label: "anon miner",
|
||||
text: "ANON DISCOVERS SOLO MINING AND IMMEDIATELY BECOMES A PROBLEM.",
|
||||
image: "/stickers/anon-miner.svg",
|
||||
},
|
||||
{
|
||||
label: "fiat npc",
|
||||
text: "I JUST USE A NORMAL HEATER BECAUSE THE BANK SAID IT WAS FINE.",
|
||||
image: "/stickers/fiat-npc.svg",
|
||||
},
|
||||
{
|
||||
label: "chad hash",
|
||||
text: "LOUD, WARM, WRONG, AND STILL MORE SOVEREIGN THAN YOUR SAVINGS ACCOUNT.",
|
||||
image: "/stickers/chad-hash.svg",
|
||||
},
|
||||
{
|
||||
label: "money printer",
|
||||
text: "CENTRAL BANK MONETARY POLICY, ARTIST'S IMPRESSION.",
|
||||
image: "/stickers/money-printer.svg",
|
||||
},
|
||||
{
|
||||
label: "block denied",
|
||||
text: "THE NETWORK REVIEWED YOUR NONCE AND CHOSE VIOLENCE.",
|
||||
image: "/stickers/block-denied.svg",
|
||||
},
|
||||
{
|
||||
label: "boomer unit",
|
||||
text: "LEGACY HEATER ACHIEVES ZERO HASHES PER RETIREMENT.",
|
||||
image: "/stickers/boomer-unit.svg",
|
||||
},
|
||||
{
|
||||
label: "chart crime",
|
||||
text: "TECHNICAL ANALYSIS CONDUCTED BY A FAN WITH A WALLET.",
|
||||
image: "/stickers/chart-crime.svg",
|
||||
},
|
||||
{
|
||||
label: "odds wizard",
|
||||
text: "I HAVE CONSULTED THE ODDS AND THEY SAID LOL.",
|
||||
image: "/stickers/odds-wizard.svg",
|
||||
},
|
||||
{
|
||||
label: "gif fan panic",
|
||||
kind: "gif",
|
||||
text: "LIVE FAN FOOTAGE AFTER HASHRATE MOVES BY A DECIMAL.",
|
||||
image: "/stickers/fan-panic.svg",
|
||||
},
|
||||
{
|
||||
label: "gif printer",
|
||||
kind: "gif",
|
||||
text: "FIAT POLICY IN ONE LOOP.",
|
||||
image: "/stickers/printer-loop.svg",
|
||||
},
|
||||
{
|
||||
label: "gif block hopium",
|
||||
kind: "gif",
|
||||
text: "SOLO MINER WATCHING EVERY NEW BLOCK LIKE IT OWES HIM MONEY.",
|
||||
image: "/stickers/block-hopium.svg",
|
||||
},
|
||||
{
|
||||
label: "gif heat death",
|
||||
kind: "gif",
|
||||
text: "THE ROOM IS WARMER THAN THE INVESTMENT THESIS.",
|
||||
image: "/stickers/heat-death.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export function stickerContent(sticker: ChatSticker): string {
|
||||
const image = sticker.image ? `${IMAGE_MARKER}${sticker.image}] ` : "";
|
||||
return `${STICKER_PREFIX} ${image}${sticker.kind === "gif" ? `${GIF_MARKER} ` : ""}${sticker.text}`;
|
||||
}
|
||||
|
||||
export function unsticker(
|
||||
content: string,
|
||||
): { text: string; kind: "sticker" | "gif"; image?: string } | null {
|
||||
if (!content.startsWith(`${STICKER_PREFIX} `)) return null;
|
||||
let body = content.slice(STICKER_PREFIX.length + 1);
|
||||
let image: string | undefined;
|
||||
if (body.startsWith(IMAGE_MARKER)) {
|
||||
const end = body.indexOf("] ");
|
||||
if (end > IMAGE_MARKER.length) {
|
||||
image = body.slice(IMAGE_MARKER.length, end);
|
||||
body = body.slice(end + 2);
|
||||
}
|
||||
}
|
||||
if (body.startsWith(`${GIF_MARKER} `)) {
|
||||
const parsed = { text: body.slice(GIF_MARKER.length + 1), kind: "gif" as const };
|
||||
return image ? { ...parsed, image } : parsed;
|
||||
}
|
||||
const parsed = { text: body, kind: "sticker" as const };
|
||||
return image ? { ...parsed, image } : parsed;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,768 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import * as chat from "../services/chat";
|
||||
import type { ChatMessage, ChatProfile, ChatReaction } from "../services/chat";
|
||||
import { CHAT_STICKERS, stickerContent, unsticker, type ChatSticker } from "../chatStickers";
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const reactions = ref<Record<string, Record<string, string[]>>>({});
|
||||
const profiles = ref<Record<string, ChatProfile>>({});
|
||||
const draft = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
const showStickers = ref(false);
|
||||
const replyTarget = ref<ChatMessage | null>(null);
|
||||
const listEl = ref<HTMLElement | null>(null);
|
||||
let sub: { close: () => void } | null = null;
|
||||
|
||||
const ownPubkey = computed(() => chat.pubkeyFromNpub(auth.npub));
|
||||
const canSend = computed(() => auth.hasActiveSigner);
|
||||
const mediaStickers = computed(() => CHAT_STICKERS.filter((sticker) => sticker.image));
|
||||
const textStickers = computed(() => CHAT_STICKERS.filter((sticker) => !sticker.image));
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isLoggedIn) void start();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
sub?.close();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
(loggedIn) => {
|
||||
if (loggedIn) void start();
|
||||
else {
|
||||
sub?.close();
|
||||
sub = null;
|
||||
messages.value = [];
|
||||
profiles.value = {};
|
||||
reactions.value = {};
|
||||
replyTarget.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
if (!canSend.value) void auth.restoreSavedSigner();
|
||||
void scrollBottom();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function start(): Promise<void> {
|
||||
if (sub) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
messages.value = await chat.loadChat();
|
||||
await loadReactions();
|
||||
await loadMissingProfiles();
|
||||
sub = chat.subscribeChat(
|
||||
(message) => {
|
||||
if (!messages.value.some((m) => m.id === message.id)) {
|
||||
messages.value.push(message);
|
||||
messages.value.sort((a, b) => a.createdAt - b.createdAt);
|
||||
void loadMissingProfiles();
|
||||
void scrollBottom();
|
||||
}
|
||||
},
|
||||
(reaction) => addReaction(reaction),
|
||||
);
|
||||
await scrollBottom();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Chat failed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReactions(): Promise<void> {
|
||||
const loaded = await chat.loadReactions(messages.value.map((m) => m.id));
|
||||
reactions.value = {};
|
||||
for (const reaction of loaded) addReaction(reaction);
|
||||
}
|
||||
|
||||
async function loadMissingProfiles(): Promise<void> {
|
||||
const authors = messages.value.map((m) => m.pubkey);
|
||||
if (ownPubkey.value) authors.push(ownPubkey.value);
|
||||
const missing = [...new Set(authors)].filter((p) => !profiles.value[p]);
|
||||
if (!missing.length) return;
|
||||
profiles.value = { ...profiles.value, ...(await chat.loadProfiles(missing)) };
|
||||
}
|
||||
|
||||
async function send(): Promise<void> {
|
||||
const content = draft.value.trim();
|
||||
if (!content) return;
|
||||
await sendContent(content, replyTarget.value ?? undefined);
|
||||
draft.value = "";
|
||||
replyTarget.value = null;
|
||||
}
|
||||
|
||||
async function sendSticker(sticker: ChatSticker): Promise<void> {
|
||||
await sendContent(stickerContent(sticker), replyTarget.value ?? undefined);
|
||||
replyTarget.value = null;
|
||||
showStickers.value = false;
|
||||
}
|
||||
|
||||
async function sendContent(content: string, replyTo?: ChatMessage): Promise<void> {
|
||||
error.value = "";
|
||||
try {
|
||||
const message = await chat.sendChat(content, replyTo ? { id: replyTo.id, pubkey: replyTo.pubkey } : undefined);
|
||||
if (!messages.value.some((m) => m.id === message.id)) messages.value.push(message);
|
||||
await loadMissingProfiles();
|
||||
await scrollBottom();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && /connect|signer|auth|missing remote|aborted/i.test(e.message)) {
|
||||
await auth.restoreSavedSigner();
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : "Could not send";
|
||||
}
|
||||
}
|
||||
|
||||
async function react(message: ChatMessage, content: string): Promise<void> {
|
||||
error.value = "";
|
||||
try {
|
||||
addReaction(await chat.sendReaction(message, content));
|
||||
} catch (e) {
|
||||
if (e instanceof Error && /connect|signer|auth|missing remote|aborted/i.test(e.message)) {
|
||||
await auth.restoreSavedSigner();
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : "Could not react";
|
||||
}
|
||||
}
|
||||
|
||||
function addReaction(reaction: ChatReaction): void {
|
||||
if (!reaction.messageId) return;
|
||||
const existing = reactions.value[reaction.messageId]?.[reaction.content] ?? [];
|
||||
if (existing.includes(reaction.pubkey)) return;
|
||||
reactions.value = {
|
||||
...reactions.value,
|
||||
[reaction.messageId]: {
|
||||
...(reactions.value[reaction.messageId] ?? {}),
|
||||
[reaction.content]: [...existing, reaction.pubkey],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function profile(pubkey: string): ChatProfile {
|
||||
return profiles.value[pubkey] ?? { pubkey, name: chat.shortPubkey(pubkey), picture: "" };
|
||||
}
|
||||
|
||||
function timeLabel(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function stickerText(content: string): string | null {
|
||||
return unsticker(content)?.text ?? null;
|
||||
}
|
||||
|
||||
function stickerKind(content: string): "sticker" | "gif" | null {
|
||||
return unsticker(content)?.kind ?? null;
|
||||
}
|
||||
|
||||
function stickerImage(content: string): string | null {
|
||||
return unsticker(content)?.image ?? null;
|
||||
}
|
||||
|
||||
function replyPreview(message: ChatMessage): ChatMessage | null {
|
||||
if (!message.replyToId) return null;
|
||||
return messages.value.find((m) => m.id === message.replyToId) ?? null;
|
||||
}
|
||||
|
||||
function shortText(content: string): string {
|
||||
const sticker = unsticker(content);
|
||||
const text = sticker?.text ?? content;
|
||||
return text.length > 78 ? `${text.slice(0, 78)}...` : text;
|
||||
}
|
||||
|
||||
function reactionEntries(message: ChatMessage): Array<{ content: string; count: number; mine: boolean }> {
|
||||
const own = ownPubkey.value;
|
||||
return Object.entries(reactions.value[message.id] ?? {}).map(([content, pubkeys]) => ({
|
||||
content,
|
||||
count: pubkeys.length,
|
||||
mine: !!own && pubkeys.includes(own),
|
||||
}));
|
||||
}
|
||||
|
||||
async function scrollBottom(): Promise<void> {
|
||||
await nextTick();
|
||||
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="chat-backdrop" @click="emit('close')" />
|
||||
<aside :class="['chat-drawer', { open }]">
|
||||
<header>
|
||||
<div>
|
||||
<div class="label">private nostr-login chat</div>
|
||||
<h2 class="glow-cyan">mining desk heckle box</h2>
|
||||
</div>
|
||||
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<div v-if="!canSend" class="notice reconnect">
|
||||
<span>
|
||||
Logged in, but the signer is not active in this tab. Reading works; sending needs a restored signer.
|
||||
</span>
|
||||
<button class="thin" :disabled="auth.busy" @click="auth.reconnectRemoteApp()">
|
||||
{{ auth.busy ? "opening" : "reconnect" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="notice err">{{ error }}</div>
|
||||
|
||||
<div ref="listEl" class="messages">
|
||||
<div v-if="loading" class="empty muted">loading relay gossip...</div>
|
||||
<div v-else-if="!messages.length" class="empty muted">no chat yet. terrifying discipline.</div>
|
||||
<article
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:class="['msg', { own: message.pubkey === ownPubkey }]"
|
||||
>
|
||||
<img v-if="profile(message.pubkey).picture" :src="profile(message.pubkey).picture" alt="" />
|
||||
<div v-else class="avatar">{{ profile(message.pubkey).name.slice(0, 2).toUpperCase() }}</div>
|
||||
<div class="bubble">
|
||||
<div class="meta">
|
||||
<strong>{{ profile(message.pubkey).name }}</strong>
|
||||
<span>{{ timeLabel(message.createdAt) }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="replyPreview(message)"
|
||||
type="button"
|
||||
class="reply-quote"
|
||||
@click="replyTarget = replyPreview(message)"
|
||||
>
|
||||
<strong>{{ profile(replyPreview(message)?.pubkey || '').name }}</strong>
|
||||
<span>{{ shortText(replyPreview(message)?.content || '') }}</span>
|
||||
</button>
|
||||
<p v-if="!stickerText(message.content)">{{ message.content }}</p>
|
||||
<div v-else :class="['sticker', stickerKind(message.content)]">
|
||||
<img v-if="stickerImage(message.content)" :src="stickerImage(message.content) || ''" alt="" />
|
||||
<span>{{ stickerText(message.content) }}</span>
|
||||
</div>
|
||||
<div class="message-actions">
|
||||
<button type="button" @click="replyTarget = message">reply</button>
|
||||
<button type="button" @click="react(message, '+')">+</button>
|
||||
<button type="button" @click="react(message, '🔥')">fire</button>
|
||||
<button type="button" @click="react(message, '😂')">lol</button>
|
||||
</div>
|
||||
<div v-if="reactionEntries(message).length" class="reactions">
|
||||
<button
|
||||
v-for="entry in reactionEntries(message)"
|
||||
:key="entry.content"
|
||||
type="button"
|
||||
:class="{ mine: entry.mine }"
|
||||
@click="react(message, entry.content)"
|
||||
>
|
||||
{{ entry.content }} {{ entry.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="showStickers" class="sticker-modal" @click.self="showStickers = false">
|
||||
<section class="sticker-dialog" aria-label="chat stickers">
|
||||
<div class="sticker-head">
|
||||
<div>
|
||||
<div class="label">stickers</div>
|
||||
<strong class="glow-amber">{{ CHAT_STICKERS.length }} signed insults</strong>
|
||||
</div>
|
||||
<button type="button" class="thin" aria-label="close stickers" @click="showStickers = false">×</button>
|
||||
</div>
|
||||
<div class="sticker-tray">
|
||||
<div class="sticker-section">
|
||||
<div class="section-title">image / gif stickers</div>
|
||||
<div class="sticker-grid media-grid">
|
||||
<button
|
||||
v-for="sticker in mediaStickers"
|
||||
:key="sticker.label"
|
||||
type="button"
|
||||
:disabled="!canSend"
|
||||
@click="sendSticker(sticker)"
|
||||
>
|
||||
<img :src="sticker.image || ''" alt="" />
|
||||
<span>{{ sticker.label }}</span>
|
||||
<small>{{ sticker.text }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticker-section">
|
||||
<div class="section-title">text stickers</div>
|
||||
<div class="sticker-grid text-grid">
|
||||
<button
|
||||
v-for="sticker in textStickers"
|
||||
:key="sticker.label"
|
||||
type="button"
|
||||
:disabled="!canSend"
|
||||
@click="sendSticker(sticker)"
|
||||
>
|
||||
<span>{{ sticker.label }}</span>
|
||||
<small>{{ sticker.text }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<div v-if="replyTarget" class="replying">
|
||||
<div>
|
||||
<span class="label">replying to {{ profile(replyTarget.pubkey).name }}</span>
|
||||
<p>{{ shortText(replyTarget.content) }}</p>
|
||||
</div>
|
||||
<button type="button" class="thin" @click="replyTarget = null">×</button>
|
||||
</div>
|
||||
<div class="composer-tools">
|
||||
<button
|
||||
type="button"
|
||||
class="sticker-toggle"
|
||||
:disabled="!canSend"
|
||||
@click="showStickers = !showStickers"
|
||||
>
|
||||
stickers
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="draft"
|
||||
:disabled="!canSend"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
placeholder="type something irresponsible but signed..."
|
||||
/>
|
||||
<button class="primary" :disabled="!canSend || !draft.trim()">send</button>
|
||||
</form>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 18;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.chat-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: min(430px, 92vw);
|
||||
height: 100dvh;
|
||||
--chat-pad: 16px;
|
||||
--composer-bottom: 24px;
|
||||
--composer-height: 70px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: var(--chat-pad);
|
||||
background: rgba(7, 9, 15, 0.97);
|
||||
border-left: 1px solid var(--line-bright);
|
||||
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.45);
|
||||
transform: translateX(102%);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.chat-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.notice {
|
||||
border: 1px solid var(--line);
|
||||
color: var(--fg-1);
|
||||
padding: 9px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.notice.err {
|
||||
border-color: var(--neon-red);
|
||||
color: var(--neon-red);
|
||||
}
|
||||
.notice.reconnect {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.notice.reconnect span {
|
||||
min-width: 0;
|
||||
}
|
||||
.notice.reconnect button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.messages {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-right: 4px;
|
||||
padding-bottom: calc(var(--composer-height) + var(--composer-bottom) + 18px);
|
||||
}
|
||||
.empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.msg {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
.msg.own {
|
||||
grid-template-columns: 1fr 34px;
|
||||
}
|
||||
.msg.own img,
|
||||
.msg.own .avatar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
.msg.own .bubble {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
img,
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--line-bright);
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--neon-cyan);
|
||||
background: var(--bg-2);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bubble {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.own .bubble {
|
||||
border-color: var(--neon-cyan);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--fg-2);
|
||||
font-size: 10px;
|
||||
}
|
||||
.meta strong {
|
||||
color: var(--fg-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reply-quote {
|
||||
width: 100%;
|
||||
margin-top: 7px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-color: var(--line);
|
||||
color: var(--fg-2);
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 10px;
|
||||
}
|
||||
.reply-quote strong {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
.reply-quote span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
p {
|
||||
margin: 5px 0 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
}
|
||||
.message-actions,
|
||||
.reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.message-actions button,
|
||||
.reactions button {
|
||||
min-height: 22px;
|
||||
padding: 3px 6px;
|
||||
color: var(--fg-2);
|
||||
border-color: var(--line);
|
||||
font-size: 9px;
|
||||
}
|
||||
.reactions button {
|
||||
color: var(--neon-amber);
|
||||
background: rgba(255, 216, 74, 0.04);
|
||||
}
|
||||
.reactions button.mine {
|
||||
border-color: var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
.sticker {
|
||||
border: 1px solid var(--neon-magenta);
|
||||
color: var(--neon-amber);
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
box-shadow: inset 0 0 18px rgba(255, 61, 240, 0.08);
|
||||
}
|
||||
.sticker img {
|
||||
display: block;
|
||||
width: min(220px, 100%);
|
||||
aspect-ratio: 1 / 1;
|
||||
height: auto;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--line-bright);
|
||||
object-fit: contain;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.sticker span {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sticker.gif {
|
||||
animation: sticker-bounce 1.4s steps(2, end) infinite;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 61, 240, 0.12), rgba(41, 255, 230, 0.08)),
|
||||
rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.composer {
|
||||
position: fixed;
|
||||
right: var(--chat-pad);
|
||||
bottom: var(--composer-bottom);
|
||||
z-index: 4;
|
||||
width: calc(min(430px, 92vw) - (var(--chat-pad) * 2));
|
||||
min-height: var(--composer-height);
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr) 64px;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(7, 9, 15, 0.97);
|
||||
}
|
||||
.replying {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.replying p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--fg-1);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.replying button {
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.composer-tools {
|
||||
min-width: 0;
|
||||
}
|
||||
.sticker-modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
align-items: end;
|
||||
padding: 16px;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.sticker-dialog {
|
||||
min-height: 0;
|
||||
max-height: min(520px, calc(100% - 24px));
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--neon-magenta);
|
||||
background: var(--bg-1);
|
||||
box-shadow: var(--shadow-magenta);
|
||||
}
|
||||
.sticker-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
.sticker-head strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.sticker-tray {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.sticker-section + .sticker-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.section-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--fg-2);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sticker-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.text-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sticker-grid button {
|
||||
min-width: 0;
|
||||
min-height: 138px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 9px 10px;
|
||||
color: var(--neon-amber);
|
||||
border-color: var(--line-bright);
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.text-grid button {
|
||||
min-height: 74px;
|
||||
}
|
||||
.sticker-grid button img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 120px;
|
||||
max-height: 156px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.sticker-grid button span {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
.sticker-grid button small {
|
||||
color: var(--fg-1);
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.sticker-toggle {
|
||||
width: 100%;
|
||||
height: 58px;
|
||||
padding: 0 6px;
|
||||
color: var(--neon-amber);
|
||||
border-color: var(--line-bright);
|
||||
font-size: 10px;
|
||||
}
|
||||
textarea {
|
||||
height: 58px;
|
||||
min-height: 58px;
|
||||
max-height: 92px;
|
||||
resize: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.composer button {
|
||||
align-self: start;
|
||||
height: 58px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
@keyframes sticker-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
filter: hue-rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(1px, -1px);
|
||||
filter: hue-rotate(22deg);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.chat-backdrop {
|
||||
background: rgba(0, 0, 0, 0.44);
|
||||
}
|
||||
.chat-drawer {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
height: 100svh;
|
||||
max-height: none;
|
||||
--chat-pad: 14px;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
padding: 14px 14px calc(72px + env(safe-area-inset-bottom));
|
||||
transform: translateY(105%);
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line-bright);
|
||||
}
|
||||
.chat-drawer.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.messages {
|
||||
gap: 10px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.composer {
|
||||
position: static;
|
||||
width: auto;
|
||||
grid-template-columns: 72px minmax(0, 1fr) 58px;
|
||||
gap: 6px;
|
||||
}
|
||||
.sticker-modal {
|
||||
padding: 10px;
|
||||
}
|
||||
.sticker-dialog {
|
||||
max-height: calc(100% - 12px);
|
||||
}
|
||||
.sticker-tray {
|
||||
padding-right: 0;
|
||||
}
|
||||
.media-grid,
|
||||
.text-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { LOTTERY_LINES } from "../strings";
|
||||
|
||||
const props = defineProps<{ totalThs: number; networkEh: number }>();
|
||||
|
||||
const FALLBACK_NETWORK_EH_S = 720;
|
||||
const SECONDS_PER_BLOCK = 600;
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
const activeNetworkEh = computed(() => props.networkEh > 0 ? props.networkEh : FALLBACK_NETWORK_EH_S);
|
||||
|
||||
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 / (activeNetworkEh.value * 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;
|
||||
}, 10_000);
|
||||
});
|
||||
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="source muted">
|
||||
calculated against {{ activeNetworkEh.toFixed(0) }} EH/s network hashrate
|
||||
</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;
|
||||
min-width: 0;
|
||||
}
|
||||
.source {
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.line {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
min-height: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
.prefix { color: var(--neon-amber); font-weight: 700; }
|
||||
.text {
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,231 @@
|
||||
<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; bestWork: number | undefined }>();
|
||||
|
||||
const isBoomerHeater = computed(() => props.miner.nickname === "Boomer Heater");
|
||||
|
||||
const slug = computed(() => {
|
||||
// Map nickname → roast key
|
||||
const map: Record<string, string> = {
|
||||
QU4CK: "nerdqaxe",
|
||||
P1XEL: "bitaxe",
|
||||
BigPapa: "bitaxe",
|
||||
N4N0: "avalon-nano-3",
|
||||
M1N1: "avalon-mini-3",
|
||||
};
|
||||
return map[props.miner.nickname] ?? "unknown";
|
||||
});
|
||||
|
||||
const roast = computed(() =>
|
||||
isBoomerHeater.value ? "The dumb shit your fiat friends have." : (ROASTS[slug.value] ?? ROASTS["unknown"]),
|
||||
);
|
||||
|
||||
const statusLabel = computed(() => STATUS_LINES[props.miner.status]);
|
||||
const displayStatusLabel = computed(() => isBoomerHeater.value ? "dead" : statusLabel.value);
|
||||
const statusMarker = computed(() => isBoomerHeater.value ? "☠" : "");
|
||||
const statusGlow = computed(() => {
|
||||
if (isBoomerHeater.value) return "glow-red";
|
||||
switch (props.miner.status) {
|
||||
case "hashing": return "glow-green";
|
||||
case "stale": return "glow-amber";
|
||||
case "idle": return "glow-red";
|
||||
}
|
||||
});
|
||||
|
||||
const lastShareLabel = computed(() => {
|
||||
if (isBoomerHeater.value) return "Nothing";
|
||||
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 acceptedWork = computed(() => isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.diffAcceptedSum));
|
||||
const contributionWork = computed(() =>
|
||||
isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.contributionAcceptedWork ?? props.miner.diffAcceptedSum),
|
||||
);
|
||||
const contributionShare = computed(() =>
|
||||
isBoomerHeater.value ? "0.00%" : `${(props.miner.contributionPct ?? 0).toFixed(2)}%`,
|
||||
);
|
||||
const bestWork = computed(() => {
|
||||
if (isBoomerHeater.value) return "Fuck all";
|
||||
return props.bestWork && props.bestWork > 0 ? formatBig(props.bestWork) : "collecting";
|
||||
});
|
||||
|
||||
const staleNote = computed(() => {
|
||||
if (isBoomerHeater.value) return "warming a room while contributing nothing to civilization.";
|
||||
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 v-if="statusMarker" class="dead-mark">{{ statusMarker }}</span>
|
||||
<span v-else class="dot pulse" />
|
||||
{{ displayStatusLabel }}
|
||||
</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">
|
||||
{{ isBoomerHeater ? "No hashes" : 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">{{ isBoomerHeater ? "None" : lifetimeShares.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">best calc</div>
|
||||
<div class="value glow-amber">{{ bestWork }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">reward share</div>
|
||||
<div class="value glow-green">{{ contributionShare }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">reward work</div>
|
||||
<div class="value">{{ contributionWork }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">reject %</div>
|
||||
<div :class="['value', miner.rejectPct > 5 ? 'glow-red' : '']">
|
||||
{{ isBoomerHeater ? "not even rejected" : `${miner.rejectPct.toFixed(2)}%` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">accepted work</div>
|
||||
<div class="value">{{ acceptedWork }}</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' || isBoomerHeater" 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;
|
||||
}
|
||||
.dead-mark {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
text-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>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
|
||||
import { useRotatingCopy } from "../composables/useRotatingCopy";
|
||||
import { RACE_SCALE_NOTES, RACE_TITLES } from "../copy";
|
||||
import { multiple } from "../format";
|
||||
|
||||
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
|
||||
const raceTitle = useRotatingCopy(RACE_TITLES);
|
||||
const scaleNote = useRotatingCopy(RACE_SCALE_NOTES);
|
||||
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
|
||||
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
|
||||
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
|
||||
|
||||
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 fiat and a pension.",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const cosmicRows = computed(() => {
|
||||
if (!networkEh.value || !totalThs.value) return [];
|
||||
const totalNetworkThs = networkEh.value * 1_000_000;
|
||||
const largePoolThs = totalNetworkThs * 0.25;
|
||||
return [
|
||||
{
|
||||
key: "large-pool",
|
||||
name: "Large pool-ish bully",
|
||||
value: `${(largePoolThs / 1_000_000).toFixed(0)} EH/s`,
|
||||
width: 78,
|
||||
joke: "illustrative 25% chunk of the monster",
|
||||
},
|
||||
{
|
||||
key: "network",
|
||||
name: "Total Bitcoin network",
|
||||
value: `${networkEh.value.toFixed(0)} EH/s`,
|
||||
width: 100,
|
||||
joke: `${multiple(networkMultiple.value)} your shelf. rude but factual.`,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function minerKey(m: MinerStat): string {
|
||||
return m.authUsername || m.remoteHost || m.nickname;
|
||||
}
|
||||
|
||||
function imageFor(m: MinerStat): string {
|
||||
if (m.nickname === "QU4CK") return "/miners/nerdqaxe.svg";
|
||||
if (m.nickname === "P1XEL") return "/miners/bitaxe.svg";
|
||||
if (m.nickname === "BigPapa" && /bitaxe/i.test(m.model)) return "/miners/bigpapa-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">{{ raceTitle }}</h2>
|
||||
</div>
|
||||
<span class="muted">{{ scaleNote }}</span>
|
||||
</header>
|
||||
|
||||
<div class="track">
|
||||
<div class="section-label">Actual miners</div>
|
||||
<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 v-if="cosmicRows.length" class="section-label cosmic-label">
|
||||
Big pools / total network · separate scale so the kids remain visible
|
||||
</div>
|
||||
<div v-for="row in cosmicRows" :key="row.key" class="lane cosmic">
|
||||
<div class="rank">∞</div>
|
||||
<div class="network-icon">₿</div>
|
||||
<div class="runner">
|
||||
<div class="meta">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.value }} · {{ row.joke }}</span>
|
||||
</div>
|
||||
<div class="bar cosmic-bar">
|
||||
<span :style="{ width: `${row.width}%` }">
|
||||
<i>NOPE</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;
|
||||
}
|
||||
.section-label {
|
||||
color: var(--neon-amber);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.cosmic-label {
|
||||
color: var(--neon-red);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.network-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--neon-red);
|
||||
color: var(--neon-amber);
|
||||
font-size: 28px;
|
||||
box-shadow: 0 0 14px rgba(255, 79, 120, 0.24);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.cosmic .bar span {
|
||||
background: linear-gradient(90deg, #ff4f78, #ffd84a);
|
||||
}
|
||||
.cosmic .meta strong {
|
||||
color: var(--neon-red);
|
||||
}
|
||||
.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>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { DatumSnapshot } from "../types";
|
||||
import { useRotatingCopy } from "../composables/useRotatingCopy";
|
||||
import { BLOCK_LABELS, HASHRATE_LABELS, NETWORK_LABELS } from "../copy";
|
||||
import { multiple } from "../format";
|
||||
|
||||
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
|
||||
|
||||
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
|
||||
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
|
||||
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
|
||||
const networkRoast = computed(() => {
|
||||
if (!networkMultiple.value) return "waiting for the grown-up numbers";
|
||||
if (networkMultiple.value > 20_000_000) return "the network is a freight train; this shelf is a polite cough.";
|
||||
if (networkMultiple.value > 1_000_000) return "big miners brought warehouses; you brought character.";
|
||||
return "we are briefly visible on the cosmic spreadsheet.";
|
||||
});
|
||||
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 hashrateLabel = useRotatingCopy(HASHRATE_LABELS);
|
||||
const blockLabel = useRotatingCopy(BLOCK_LABELS);
|
||||
const networkLabel = useRotatingCopy(NETWORK_LABELS);
|
||||
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">{{ hashrateLabel }}</div>
|
||||
<div class="number glow-cyan">
|
||||
{{ totalThs.toFixed(2) }}
|
||||
<span class="unit">Th/s</span>
|
||||
</div>
|
||||
<div class="network-roast muted">{{ networkRoast }}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="cell">
|
||||
<div class="label">{{ blockLabel }}</div>
|
||||
<div class="value">{{ blockHeight > 0 ? blockHeight.toLocaleString() : "—" }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label">{{ networkLabel }}</div>
|
||||
<div class="value glow-amber">{{ networkEh > 0 ? `${networkEh.toFixed(0)} EH/s` : "—" }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="label">network is bigger by</div>
|
||||
<div class="value glow-red">{{ networkMultiple > 0 ? multiple(networkMultiple) : "—" }}</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);
|
||||
}
|
||||
.network-roast {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.right {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.cell {
|
||||
min-height: 74px;
|
||||
display: grid;
|
||||
grid-template-rows: 34px 1fr;
|
||||
align-content: start;
|
||||
}
|
||||
.cell .label {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cell .value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.right { grid-template-columns: repeat(2, 1fr); }
|
||||
.left .number { font-size: 44px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { RELEASE_NOTES } from "../releaseNotes";
|
||||
|
||||
defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="release-backdrop" @click="emit('close')" />
|
||||
<aside :class="['release-drawer', { open }]">
|
||||
<header>
|
||||
<div>
|
||||
<div class="label">release notes</div>
|
||||
<h2 class="glow-amber">what changed while the heaters screamed</h2>
|
||||
</div>
|
||||
<button class="thin" aria-label="close release notes" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<div class="notes">
|
||||
<article v-for="note in RELEASE_NOTES" :key="note.hash" class="note">
|
||||
<div class="note-head">
|
||||
<strong>{{ note.title }}</strong>
|
||||
<code>{{ note.hash }}</code>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="bullet in note.bullets" :key="bullet">{{ bullet }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 18;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.release-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 21;
|
||||
width: min(520px, 94vw);
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: rgba(7, 9, 15, 0.98);
|
||||
border-left: 1px solid var(--line-bright);
|
||||
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.48);
|
||||
transform: translateX(102%);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.release-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
header,
|
||||
.note-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.notes {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.note {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
padding: 12px;
|
||||
}
|
||||
.note-head strong {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
code {
|
||||
color: var(--fg-2);
|
||||
font-size: 10px;
|
||||
}
|
||||
ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
li {
|
||||
margin: 5px 0;
|
||||
color: var(--fg-1);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.release-drawer {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
height: 65vh;
|
||||
transform: translateY(105%);
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line-bright);
|
||||
}
|
||||
.release-drawer.open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
|
||||
import { multiple, tinyPercentAsOdds } from "../format";
|
||||
|
||||
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
|
||||
|
||||
const HASHES_PER_TH = 1e12;
|
||||
const DIFF_ONE_HASHES = 2 ** 32;
|
||||
const BLOCKS_PER_DAY = 144;
|
||||
const DAYS_PER_YEAR = 365;
|
||||
const SUBSIDY_SATS = 312_500_000;
|
||||
|
||||
const miners = computed(() => props.snapshot?.miners ?? []);
|
||||
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
|
||||
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
|
||||
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
|
||||
const totalWatts = computed(() => miners.value.reduce((sum, m) => sum + m.watts, 0));
|
||||
const rewardSats = computed(() => props.snapshot?.job.blockValueSats || SUBSIDY_SATS);
|
||||
const difficulty = computed(() => props.snapshot?.job.difficulty || 0);
|
||||
const bestByMiner = computed(() => bestWorkByMiner(props.history));
|
||||
const poolBest = computed(() => Math.max(0, ...Object.values(bestByMiner.value)));
|
||||
const windowHours = computed(() => {
|
||||
const first = props.history[0];
|
||||
const last = props.history.at(-1);
|
||||
if (!first || !last || last.t <= first.t) return 0;
|
||||
return (last.t - first.t) / 3600000;
|
||||
});
|
||||
const shareVelocity = computed(() => {
|
||||
const first = props.history[0];
|
||||
const last = props.history.at(-1);
|
||||
if (!first || !last || windowHours.value <= 0) return 0;
|
||||
return Math.max(0, last.accepted - first.accepted) / windowHours.value;
|
||||
});
|
||||
const expectedSecondsToBlock = computed(() => {
|
||||
if (!difficulty.value || totalThs.value <= 0) return 0;
|
||||
return (difficulty.value * DIFF_ONE_HASHES) / (totalThs.value * HASHES_PER_TH);
|
||||
});
|
||||
const expectedBlocksYear = computed(() => expectedSecondsToBlock.value > 0 ? (DAYS_PER_YEAR * 86400) / expectedSecondsToBlock.value : 0);
|
||||
const expectedSatsDay = computed(() => expectedBlocksYear.value > 0 ? (expectedBlocksYear.value / DAYS_PER_YEAR) * rewardSats.value : 0);
|
||||
const heatBtu = computed(() => totalWatts.value * 3.412);
|
||||
const poolEfficiency = computed(() => totalWatts.value > 0 ? totalThs.value / totalWatts.value : 0);
|
||||
const connectionHealth = computed(() => {
|
||||
const pool = props.snapshot?.pool;
|
||||
if (!pool) return "collecting";
|
||||
if (pool.totalSubscriptions < miners.value.length) return "missing subscriptions";
|
||||
if (miners.value.some((m) => m.status !== "hashing")) return "somebody wandered off";
|
||||
if (pool.activeThreads <= 0) return "threads unknown";
|
||||
return "nominal, suspiciously";
|
||||
});
|
||||
|
||||
const minerRows = computed(() => {
|
||||
return miners.value
|
||||
.map((m) => {
|
||||
const key = minerKey(m);
|
||||
const samples = props.history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
|
||||
const avg = average(samples);
|
||||
const sd = stddev(samples, avg);
|
||||
const dominance = totalThs.value > 0 ? (m.hashrateThs / totalThs.value) * 100 : 0;
|
||||
const expectedPct = m.expectedHashrateThs > 0 ? (m.hashrateThs / m.expectedHashrateThs) * 100 : 0;
|
||||
const idlePct = statusPct(props.history, key, "idle") + statusPct(props.history, key, "stale");
|
||||
const best = bestByMiner.value[key] ?? 0;
|
||||
return {
|
||||
key,
|
||||
miner: m,
|
||||
avg,
|
||||
high: samples.length ? Math.max(...samples) : 0,
|
||||
low: samples.length ? Math.min(...samples) : 0,
|
||||
sd,
|
||||
dominance,
|
||||
expectedPct,
|
||||
idlePct,
|
||||
best,
|
||||
bestDifficultyPct: difficulty.value > 0 ? (best / difficulty.value) * 100 : 0,
|
||||
thPerWatt: m.watts > 0 ? m.hashrateThs / m.watts : 0,
|
||||
heatBtu: m.watts * 3.412,
|
||||
sharesPerHour: minerShareVelocity(props.history, key),
|
||||
line: personaLine(m, dominance, expectedPct, sd, idlePct),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.best - a.best || b.miner.hashrateThs - a.miner.hashrateThs);
|
||||
});
|
||||
|
||||
const leaders = computed(() => {
|
||||
const rows = minerRows.value;
|
||||
return [
|
||||
{ label: "carrier", name: [...rows].sort((a, b) => b.dominance - a.dominance)[0]?.miner.nickname ?? "-" },
|
||||
{ label: "best swing", name: [...rows].sort((a, b) => b.best - a.best)[0]?.miner.nickname ?? "-" },
|
||||
{ label: "most stable", name: [...rows].sort((a, b) => a.sd - b.sd)[0]?.miner.nickname ?? "-" },
|
||||
{ label: "reject pain", name: [...miners.value].sort((a, b) => b.rejectPct - a.rejectPct)[0]?.nickname ?? "-" },
|
||||
];
|
||||
});
|
||||
|
||||
function bestWorkByMiner(history: HistoryPoint[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (let i = 1; i < history.length; i += 1) {
|
||||
const prev = history[i - 1];
|
||||
const next = history[i];
|
||||
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
|
||||
for (const [key, work] of Object.entries(next.minerWork)) {
|
||||
const shares = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
|
||||
const workDelta = work - (prev.minerWork[key] ?? work);
|
||||
if (shares > 0 && workDelta > 0) out[key] = Math.max(out[key] ?? 0, workDelta / shares);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function minerKey(m: MinerStat): string {
|
||||
return m.authUsername || m.remoteHost || m.nickname;
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
if (!values.length) return 0;
|
||||
return values.reduce((sum, n) => sum + n, 0) / values.length;
|
||||
}
|
||||
|
||||
function stddev(values: number[], avg: number): number {
|
||||
if (values.length < 2) return 0;
|
||||
return Math.sqrt(values.reduce((sum, n) => sum + (n - avg) ** 2, 0) / values.length);
|
||||
}
|
||||
|
||||
function statusPct(history: HistoryPoint[], key: string, status: MinerStat["status"]): number {
|
||||
const seen = history.filter((p) => p.minerStatus?.[key]);
|
||||
if (!seen.length) return 0;
|
||||
return (seen.filter((p) => p.minerStatus[key] === status).length / seen.length) * 100;
|
||||
}
|
||||
|
||||
function minerShareVelocity(history: HistoryPoint[], key: string): number {
|
||||
const first = history.find((p) => Number.isFinite(p.minerShares?.[key]));
|
||||
const last = [...history].reverse().find((p) => Number.isFinite(p.minerShares?.[key]));
|
||||
if (!first || !last || last.t <= first.t) return 0;
|
||||
return Math.max(0, (last.minerShares[key] ?? 0) - (first.minerShares[key] ?? 0)) / ((last.t - first.t) / 3600000);
|
||||
}
|
||||
|
||||
function personaLine(m: MinerStat, dominance: number, expectedPct: number, sd: number, idlePct: number): string {
|
||||
if (idlePct > 30) return `${m.nickname} has spent too much of this window being decorative.`;
|
||||
if (m.rejectPct > 5) return `${m.nickname} is donating work to the void with confidence.`;
|
||||
if (expectedPct > 110) return `${m.nickname} is carrying the group project and will be insufferable about it.`;
|
||||
if (dominance > 55) return `${m.nickname} is the adult in the room, which is bleak for everyone else.`;
|
||||
if (sd > Math.max(0.4, m.hashrateThs * 0.25)) return `${m.nickname} hashes like a shopping trolley with one bad wheel.`;
|
||||
return `${m.nickname} is doing its tiny job. Nobody alert the institutions.`;
|
||||
}
|
||||
|
||||
function ageLabel(seconds: number | null): string {
|
||||
if (seconds === null) return "never";
|
||||
if (seconds < 60) return `${seconds.toFixed(0)}s`;
|
||||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
|
||||
return `${(seconds / 3600).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function duration(seconds: number): string {
|
||||
if (!seconds || !Number.isFinite(seconds)) return "collecting";
|
||||
const years = seconds / 31536000;
|
||||
if (years >= 1e9) return `${(years / 1e9).toFixed(2)}B years`;
|
||||
if (years >= 1e6) return `${(years / 1e6).toFixed(2)}M years`;
|
||||
if (years >= 1000) return `${(years / 1000).toFixed(2)}K years`;
|
||||
if (years >= 1) return `${years.toFixed(1)} years`;
|
||||
const days = seconds / 86400;
|
||||
return days >= 1 ? `${days.toFixed(1)} days` : `${(seconds / 3600).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function compact(n: number, digits = 2): string {
|
||||
if (!Number.isFinite(n)) return "-";
|
||||
if (Math.abs(n) >= 1e12) return `${(n / 1e12).toFixed(digits)}T`;
|
||||
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
|
||||
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
|
||||
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(digits)}K`;
|
||||
return n.toFixed(digits);
|
||||
}
|
||||
|
||||
function pct(n: number, digits = 4): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "0%";
|
||||
if (n < 0.001) return tinyPercentAsOdds(n);
|
||||
return `${n.toFixed(digits)}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="shameboard panel">
|
||||
<header>
|
||||
<div>
|
||||
<div class="label">leaderboard / shameboard</div>
|
||||
<h2 class="glow-magenta">Receipts from the hashing cupboard</h2>
|
||||
</div>
|
||||
<div class="health">
|
||||
<span class="label">connection</span>
|
||||
<strong>{{ connectionHealth }}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="summary">
|
||||
<div>
|
||||
<span class="label">best pool swing</span>
|
||||
<strong class="glow-amber">{{ poolBest > 0 ? compact(poolBest) : "collecting" }}</strong>
|
||||
<small>{{ difficulty > 0 ? `${pct((poolBest / difficulty) * 100)} of difficulty` : "difficulty unavailable" }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">expected block</span>
|
||||
<strong>{{ duration(expectedSecondsToBlock) }}</strong>
|
||||
<small>{{ compact(expectedBlocksYear, 8) }} blocks/year</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">imaginary payday</span>
|
||||
<strong class="glow-green">{{ compact(expectedSatsDay, 4) }}</strong>
|
||||
<small>sats/day expected</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">share velocity</span>
|
||||
<strong>{{ compact(shareVelocity, 1) }}</strong>
|
||||
<small>accepted/hour</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">space heater mode</span>
|
||||
<strong>{{ compact(totalWatts, 0) }} W</strong>
|
||||
<small>{{ compact(heatBtu, 0) }} BTU/hr · {{ compact(poolEfficiency, 4) }} TH/W</small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">network bullying</span>
|
||||
<strong class="glow-red">{{ networkMultiple > 0 ? multiple(networkMultiple) : "collecting" }}</strong>
|
||||
<small>{{ networkEh > 0 ? `${compact(networkEh, 0)} EH/s globally. easy version: the planet brought a warehouse.` : "tx1138 mempool warming up" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="leader-strip">
|
||||
<div v-for="leader in leaders" :key="leader.label">
|
||||
<span class="label">{{ leader.label }}</span>
|
||||
<strong>{{ leader.name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table">
|
||||
<div class="row head">
|
||||
<span>miner</span>
|
||||
<span>best</span>
|
||||
<span>share</span>
|
||||
<span>stability</span>
|
||||
<span>power</span>
|
||||
<span>last</span>
|
||||
</div>
|
||||
<div v-for="row in minerRows" :key="row.key" class="row">
|
||||
<div class="who">
|
||||
<strong>{{ row.miner.nickname }}</strong>
|
||||
<small>{{ row.line }}</small>
|
||||
</div>
|
||||
<span>
|
||||
{{ row.best > 0 ? compact(row.best) : "collecting" }}
|
||||
<small>{{ difficulty > 0 ? pct(row.bestDifficultyPct) : "diff ?" }}</small>
|
||||
</span>
|
||||
<span>
|
||||
{{ compact(row.dominance, 1) }}%
|
||||
<small>{{ compact(row.sharesPerHour, 1) }}/hr</small>
|
||||
</span>
|
||||
<span>
|
||||
{{ compact(row.avg) }} TH/s
|
||||
<small>{{ compact(row.low) }}-{{ compact(row.high) }} · sd {{ compact(row.sd) }}</small>
|
||||
</span>
|
||||
<span>
|
||||
{{ compact(row.thPerWatt, 4) }} TH/W
|
||||
<small>{{ row.miner.watts }} W · {{ compact(row.heatBtu, 0) }} BTU/hr</small>
|
||||
</span>
|
||||
<span>
|
||||
{{ ageLabel(row.miner.lastShareAgeS) }}
|
||||
<small>{{ compact(row.idlePct, 1) }}% idle/stale</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shameboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
header,
|
||||
.summary,
|
||||
.leader-strip,
|
||||
.row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
header {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
}
|
||||
h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.health {
|
||||
text-align: right;
|
||||
}
|
||||
.health strong {
|
||||
display: block;
|
||||
color: var(--neon-cyan);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.summary {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
.summary > div,
|
||||
.leader-strip > div {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 10px;
|
||||
min-height: 88px;
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--fg-2);
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.leader-strip {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: minmax(180px, 1.6fr) repeat(5, minmax(96px, 1fr));
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.row.head {
|
||||
color: var(--fg-2);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.who strong {
|
||||
color: var(--neon-magenta);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.leader-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.row.head {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
header,
|
||||
.summary,
|
||||
.leader-strip,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.health {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
|
||||
export function useRotatingCopy(lines: readonly string[], intervalMs = 10_000) {
|
||||
const index = ref(0);
|
||||
let timer: number | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
timer = window.setInterval(() => {
|
||||
index.value = (index.value + 1) % lines.length;
|
||||
}, intervalMs);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
return computed(() => lines[index.value] ?? lines[0] ?? "");
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export const HASHRATE_LABELS = [
|
||||
"total hashrate · all actual miners",
|
||||
"tiny thunder department",
|
||||
"hashing cupboard output",
|
||||
"combined cope velocity",
|
||||
"someone's getting warmed tonight",
|
||||
"space heaters with receipts",
|
||||
"four boards versus a planet",
|
||||
"domestic sha-256 turbulence",
|
||||
"the little engines that almost certainly won't",
|
||||
"thermal optimism index",
|
||||
"proof-of-warmth aggregate",
|
||||
"hashrate, allegedly",
|
||||
"tiny chips doing tax fraud to physics",
|
||||
"the room is warmer than the odds",
|
||||
"solo lottery horsepower",
|
||||
"sats-per-delusion engine",
|
||||
"garage-grade entropy cannon",
|
||||
"pleb furnace output",
|
||||
"all miners, no excuses",
|
||||
"bitcoin-adjacent radiator speed",
|
||||
] as const;
|
||||
|
||||
export const BLOCK_LABELS = [
|
||||
"current block height",
|
||||
"where bitcoin is right now",
|
||||
"latest block on the wall",
|
||||
"chain height, for the adults",
|
||||
"block number the kids are chasing",
|
||||
"global scoreboard tick",
|
||||
"bitcoin's current floor",
|
||||
"the moving target",
|
||||
"height of the mountain",
|
||||
"mempool says we're here",
|
||||
"sovereign block receipt",
|
||||
"latest confirmed step",
|
||||
"chain altitude",
|
||||
"block ladder position",
|
||||
"what the node believes",
|
||||
"today's impossible number",
|
||||
"where the grown-up miners are",
|
||||
"the target keeps walking",
|
||||
"bitcoin's odometer",
|
||||
"public humiliation height",
|
||||
] as const;
|
||||
|
||||
export const NETWORK_LABELS = [
|
||||
"network hashrate",
|
||||
"the actual boss fight",
|
||||
"everyone else, unfortunately",
|
||||
"global mining industrial complex",
|
||||
"the dragon outside",
|
||||
"planetary sha-256 weather",
|
||||
"big pool gravity",
|
||||
"the reason this is funny",
|
||||
"industrial-scale bullying",
|
||||
"total network furnace",
|
||||
"the adults with warehouses",
|
||||
"hashrate landlord class",
|
||||
"bitcoin's meat grinder",
|
||||
"global chip argument",
|
||||
"big miner misery wall",
|
||||
"the mountain of nope",
|
||||
"everyone versus your shelf",
|
||||
"network-sized emotional damage",
|
||||
"the pool hall full of monsters",
|
||||
"the thing Boomer Heater fears",
|
||||
] as const;
|
||||
|
||||
export const RACE_TITLES = [
|
||||
"Tiny machines, deeply unfair track",
|
||||
"Actual miners, spiritually overconfident",
|
||||
"The cupboard grand prix",
|
||||
"Drag race at walking speed",
|
||||
"The hashrate kindergarten derby",
|
||||
"Domestic miners doing their little laps",
|
||||
"Four warm rectangles and a dream",
|
||||
"Race day for the statistically doomed",
|
||||
"The space-heater invitational",
|
||||
"Actual miners, actual delusion",
|
||||
"The tiny ASIC humiliation circuit",
|
||||
"Tour de not-finding-a-block",
|
||||
"Hashrate horse race, if horses were firmware",
|
||||
"A sprint measured in geological disappointment",
|
||||
"The nobody-is-retiring 500",
|
||||
"Proof-of-work nursery slopes",
|
||||
"The little rigs are trying, allegedly",
|
||||
"Solo mining track day",
|
||||
"The warm boys championship",
|
||||
"Someone's getting warmed tonight",
|
||||
] as const;
|
||||
|
||||
export const RACE_SCALE_NOTES = [
|
||||
"actual miners · scaled up to not shame the children",
|
||||
"scaled up so the children remain visible",
|
||||
"visual mercy scale enabled",
|
||||
"not to scale, because cruelty has limits",
|
||||
"tiny miner zoom mode",
|
||||
"chart wearing reading glasses",
|
||||
"magnified for emotional support",
|
||||
"scaled up like their confidence",
|
||||
"warehouse miners cropped out for decency",
|
||||
"child-safe hashrate scale",
|
||||
"actual miners, padded for dignity",
|
||||
"zoomed in before the network laughs",
|
||||
"big pools excluded from the yardstick",
|
||||
"scale adjusted to preserve morale",
|
||||
"the children get a booster seat",
|
||||
"mercifully not linear",
|
||||
"visual participation trophy mode",
|
||||
"shown larger than nature intended",
|
||||
"small rig cinema mode",
|
||||
"the graph lies kindly",
|
||||
] as const;
|
||||
@@ -0,0 +1,26 @@
|
||||
export function compactNumber(n: number, digits = 1): string {
|
||||
if (!Number.isFinite(n)) return "-";
|
||||
if (Math.abs(n) >= 1e12) return `${(n / 1e12).toFixed(digits)}T`;
|
||||
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
|
||||
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
|
||||
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(digits)}K`;
|
||||
return n.toFixed(digits);
|
||||
}
|
||||
|
||||
export function oneIn(probability: number): string {
|
||||
if (!Number.isFinite(probability) || probability <= 0) return "basically never";
|
||||
return `1 in ${compactNumber(1 / probability, 1)}`;
|
||||
}
|
||||
|
||||
export function tinyPercentAsOdds(percent: number): string {
|
||||
if (!Number.isFinite(percent) || percent <= 0) return "0%";
|
||||
const probability = percent / 100;
|
||||
if (percent < 0.001) return oneIn(probability);
|
||||
if (percent < 1) return `${percent.toFixed(4)}%`;
|
||||
return `${percent.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function multiple(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "-";
|
||||
return `${compactNumber(n, 1)}x`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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();
|
||||
@@ -0,0 +1,96 @@
|
||||
export type ReleaseNote = {
|
||||
hash: string;
|
||||
title: string;
|
||||
bullets: string[];
|
||||
};
|
||||
|
||||
export const RELEASE_NOTES: ReleaseNote[] = [
|
||||
{
|
||||
hash: "b715c3f",
|
||||
title: "Nostr chat drawer",
|
||||
bullets: [
|
||||
"Added a signed Nostr chat panel, because apparently the miners needed a comments section for witnesses.",
|
||||
"Desktop slides in from the right; mobile rises as a half-screen sheet like a tiny gossip bunker.",
|
||||
"Loads kind-0 profile names and avatars, so the abuse has proper attribution.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "b2f81a1",
|
||||
title: "Tidier hero stats",
|
||||
bullets: [
|
||||
"Pinned the hero stat boxes down so rotating jokes stop rearranging the furniture.",
|
||||
"Aligned block height, network hashrate, and share stats like they have jobs.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "1f86bf9",
|
||||
title: "Readable odds",
|
||||
bullets: [
|
||||
"Replaced cursed science notation with human-readable odds like '1 in 2.0B', which is still awful but now legible.",
|
||||
"Slowed rotating labels and lottery jokes to 10 seconds so the dashboard stops acting caffeinated.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "fa707e2",
|
||||
title: "Network stats and rotating jokes",
|
||||
bullets: [
|
||||
"Added block height, network hashrate, and difficulty from tx1138 mempool, because Datum was keeping the good gossip to itself.",
|
||||
"Solo odds now use current network hashrate, so the humiliation is freshly calculated.",
|
||||
"Added rotating labels and a large pile of darker lottery jokes about fiat, Bitcoin, and electrical self-deception.",
|
||||
"Race view now compares actual miners to a large-pool-ish bully and the total network, on a mercy scale so the children remain visible.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "7e1f7a1",
|
||||
title: "Heat-reactive theme",
|
||||
bullets: [
|
||||
"Interface now gets redder from 10 TH/s to 70 TH/s, because someone's getting warmed tonight.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "243cd0d",
|
||||
title: "Boomer Heater got worse",
|
||||
bullets: [
|
||||
"Gave Boomer Heater a dead status, skull marker, and custom zero-stat labels because it contributes exactly fuck all.",
|
||||
"Updated the heater asset to say 'NO HASHES. JUST GHEI.' Accurate thermodynamics, questionable monetary policy.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "d6f8fd7",
|
||||
title: "Boomer Heater on status",
|
||||
bullets: [
|
||||
"Added fake Boomer Heater to the status screen, always last, where fiat appliances belong.",
|
||||
"Race copy now says 'just fiat and a pension.' No sats, no shares, no future.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "0c8e26f",
|
||||
title: "PWA and miner race",
|
||||
bullets: [
|
||||
"Made gashboard installable as a PWA with icons, service worker, and cache rules that should stop stale bundles haunting the cupboard.",
|
||||
"Remote signer login can resume after Primal/Amber returns to the PWA callback, because mobile auth should not require ritual sacrifice.",
|
||||
"Added miner race visuals with local miner assets, including the non-hashing radiator of shame.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "360b1eb",
|
||||
title: "BigPapa",
|
||||
bullets: ["Renamed the unknown Datum miner from ??? to BigPapa. Same mystery, more authority."],
|
||||
},
|
||||
{
|
||||
hash: "c2c376f",
|
||||
title: "Shameboard calculations",
|
||||
bullets: [
|
||||
"Added best observed share/work, expected block time, sats/day fantasy math, share velocity, reject pain, efficiency, BTU/hr, and per-miner insults. A spreadsheet with a personality disorder.",
|
||||
],
|
||||
},
|
||||
{
|
||||
hash: "c77c746",
|
||||
title: "Graphs and remote signer login",
|
||||
bullets: [
|
||||
"Added graph tab with hashrate telemetry, share flow, miner mix, and browser-local history so the suffering can be trended.",
|
||||
"Added Nostr Connect 'open signer app' login for Primal/Amber handoff, replacing the bunker URI homework nobody asked for.",
|
||||
"Fixed Portainer-on-Umbrel deployment compose for host networking and direct Datum URL, because Portainer's fake network was cosplay.",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/access",
|
||||
name: "access",
|
||||
component: () => import("./views/AccessView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "dashboard",
|
||||
component: () => import("./views/DashboardView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/graphs",
|
||||
name: "graphs",
|
||||
component: () => import("./views/GraphsView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("./views/LoginView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/auth/nostr-callback",
|
||||
name: "nostr-callback",
|
||||
component: () => import("./views/NostrCallbackView.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;
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ApiError, ChatSnapshot, 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;
|
||||
}
|
||||
|
||||
async function authedPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const stored = loadToken();
|
||||
if (!stored) throw new Error("Not logged in");
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${stored.token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
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");
|
||||
}
|
||||
|
||||
export async function fetchChatPubkeys(): Promise<string[]> {
|
||||
const res = await authedGet<{ pubkeys: string[] }>("/api/auth/chat-config");
|
||||
return res.pubkeys;
|
||||
}
|
||||
|
||||
export function fetchChat(): Promise<ChatSnapshot> {
|
||||
return authedGet<ChatSnapshot>("/api/chat");
|
||||
}
|
||||
|
||||
export function postChatMessage(body: {
|
||||
content: string;
|
||||
replyToId?: string;
|
||||
replyToPubkey?: string;
|
||||
}): Promise<ChatSnapshot["messages"][number]> {
|
||||
return authedPost<ChatSnapshot["messages"][number]>("/api/chat/messages", body);
|
||||
}
|
||||
|
||||
export function postChatReaction(body: {
|
||||
messageId: string;
|
||||
content: string;
|
||||
}): Promise<ChatSnapshot["reactions"][number]> {
|
||||
return authedPost<ChatSnapshot["reactions"][number]>("/api/chat/reactions", body);
|
||||
}
|
||||
|
||||
export function postChatKeyWraps(body: {
|
||||
wraps: Array<{ recipientPubkey: string; ciphertext: string }>;
|
||||
}): Promise<{ wraps: ChatSnapshot["keyWraps"] }> {
|
||||
return authedPost<{ wraps: ChatSnapshot["keyWraps"] }>("/api/chat/key-wraps", body);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { Event as NostrEvent } from "nostr-tools";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { SimplePool } from "nostr-tools/pool";
|
||||
import { fetchChat, fetchChatPubkeys, postChatKeyWraps, postChatMessage, postChatReaction } from "./api";
|
||||
import { getActiveSigner, hasActiveSigner } from "./signer";
|
||||
import type { ChatSnapshot } from "../types";
|
||||
|
||||
const RELAYS = ["wss://relay.primal.net"];
|
||||
const POLL_MS = 2500;
|
||||
const pool = new SimplePool();
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
replyToId: string;
|
||||
replyToPubkey: string;
|
||||
};
|
||||
|
||||
export type ChatReaction = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type ChatProfile = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
};
|
||||
|
||||
let reactionCache: ChatReaction[] = [];
|
||||
let cachedRecipients: string[] | null = null;
|
||||
let cachedRoomKey: CryptoKey | null = null;
|
||||
let cachedRoomKeyRaw = "";
|
||||
|
||||
export function canSendChat(): boolean {
|
||||
return hasActiveSigner();
|
||||
}
|
||||
|
||||
export function pubkeyFromNpub(npub: string | null): string | null {
|
||||
if (!npub) return null;
|
||||
try {
|
||||
const decoded = nip19.decode(npub);
|
||||
return decoded.type === "npub" ? decoded.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadChat(): Promise<ChatMessage[]> {
|
||||
const snapshot = await fetchChat();
|
||||
await ensureRoomKey(snapshot);
|
||||
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
|
||||
return (await Promise.all(snapshot.messages.map(decryptMessage))).sort((a, b) => a.createdAt - b.createdAt);
|
||||
}
|
||||
|
||||
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
|
||||
const wanted = new Set(messageIds);
|
||||
if (!reactionCache.length) {
|
||||
const snapshot = await fetchChat();
|
||||
await ensureRoomKey(snapshot);
|
||||
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
|
||||
}
|
||||
return reactionCache.filter((r) => wanted.has(r.messageId));
|
||||
}
|
||||
|
||||
export function subscribeChat(
|
||||
onMessage: (message: ChatMessage) => void,
|
||||
onReaction?: (reaction: ChatReaction) => void,
|
||||
): { close: () => void } {
|
||||
const seenMessages = new Set<string>();
|
||||
const seenReactions = new Set<string>();
|
||||
let closed = false;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (closed) return;
|
||||
const snapshot = await fetchChat();
|
||||
await ensureRoomKey(snapshot);
|
||||
for (const message of snapshot.messages) {
|
||||
if (seenMessages.has(message.id)) continue;
|
||||
seenMessages.add(message.id);
|
||||
onMessage(await decryptMessage(message));
|
||||
}
|
||||
for (const reaction of snapshot.reactions) {
|
||||
if (seenReactions.has(reaction.id)) continue;
|
||||
seenReactions.add(reaction.id);
|
||||
onReaction?.(await decryptReaction(reaction));
|
||||
}
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void poll().catch(() => {});
|
||||
}, POLL_MS);
|
||||
void poll().catch(() => {});
|
||||
|
||||
return {
|
||||
close() {
|
||||
closed = true;
|
||||
window.clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendChat(
|
||||
content: string,
|
||||
replyTo?: { id: string; pubkey: string },
|
||||
): Promise<ChatMessage> {
|
||||
const encrypted = await encryptPayload(content);
|
||||
const message = await postChatMessage({
|
||||
content: encrypted,
|
||||
...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}),
|
||||
});
|
||||
return { ...message, content };
|
||||
}
|
||||
|
||||
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
|
||||
const encrypted = await encryptPayload(content);
|
||||
const reaction = await postChatReaction({ messageId: message.id, content: encrypted });
|
||||
return { ...reaction, content };
|
||||
}
|
||||
|
||||
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
|
||||
const authors = [...new Set(pubkeys)].filter(Boolean);
|
||||
if (!authors.length) return {};
|
||||
const events = await pool.querySync(RELAYS, { kinds: [0], authors }, { maxWait: 2500 });
|
||||
const latest = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const prev = latest.get(event.pubkey);
|
||||
if (!prev || event.created_at > prev.created_at) latest.set(event.pubkey, event);
|
||||
}
|
||||
const out: Record<string, ChatProfile> = {};
|
||||
for (const [pubkey, event] of latest) {
|
||||
try {
|
||||
const meta = JSON.parse(event.content) as { name?: string; display_name?: string; picture?: string };
|
||||
out[pubkey] = {
|
||||
pubkey,
|
||||
name: meta.display_name || meta.name || shortPubkey(pubkey),
|
||||
picture: meta.picture || "",
|
||||
};
|
||||
} catch {
|
||||
out[pubkey] = { pubkey, name: shortPubkey(pubkey), picture: "" };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function shortPubkey(pubkey: string): string {
|
||||
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
|
||||
}
|
||||
|
||||
type EncryptedEnvelope = {
|
||||
v: 1;
|
||||
alg: "aes-gcm";
|
||||
room: "gashboard-chat";
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
|
||||
async function encryptPayload(plaintext: string): Promise<string> {
|
||||
const key = await ensureRoomKey();
|
||||
try {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
new TextEncoder().encode(plaintext),
|
||||
);
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
alg: "aes-gcm",
|
||||
room: "gashboard-chat",
|
||||
iv: base64(iv),
|
||||
ciphertext: base64(new Uint8Array(ciphertext)),
|
||||
} satisfies EncryptedEnvelope);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e instanceof Error
|
||||
? `Private chat encryption failed: ${e.message}. Reconnect signer if the room key is missing.`
|
||||
: "Private chat encryption failed. Reconnect signer if the room key is missing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptPayload(value: string): Promise<string> {
|
||||
try {
|
||||
const envelope = JSON.parse(value) as EncryptedEnvelope;
|
||||
if (envelope.v !== 1 || envelope.alg !== "aes-gcm" || envelope.room !== "gashboard-chat") {
|
||||
return "[encrypted with unknown chat format]";
|
||||
}
|
||||
const key = await ensureRoomKey();
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: unbase64(envelope.iv) },
|
||||
key,
|
||||
unbase64(envelope.ciphertext),
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
} catch {
|
||||
return "[encrypted - decrypt failed]";
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptMessage(message: ChatMessage): Promise<ChatMessage> {
|
||||
return { ...message, content: await decryptPayload(message.content) };
|
||||
}
|
||||
|
||||
async function decryptReaction(reaction: ChatReaction): Promise<ChatReaction> {
|
||||
return { ...reaction, content: await decryptPayload(reaction.content) };
|
||||
}
|
||||
|
||||
async function getRecipients(): Promise<string[]> {
|
||||
const signer = getActiveSigner();
|
||||
const own = await signer?.getPublicKey();
|
||||
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
|
||||
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
|
||||
}
|
||||
|
||||
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<CryptoKey> {
|
||||
if (cachedRoomKey) return cachedRoomKey;
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) throw new Error("Reconnect your signer to unlock private chat");
|
||||
const own = await signer.getPublicKey();
|
||||
const current = snapshot ?? await fetchChat();
|
||||
const wrap = current.keyWraps.find((item) => item.recipientPubkey === own);
|
||||
if (wrap) {
|
||||
const raw = await signer.decrypt(wrap.senderPubkey, wrap.ciphertext);
|
||||
return importRoomKey(raw);
|
||||
}
|
||||
|
||||
const rawBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const raw = base64(rawBytes);
|
||||
await importRoomKey(raw);
|
||||
await publishRoomKey(raw);
|
||||
return cachedRoomKey!;
|
||||
}
|
||||
|
||||
async function publishRoomKey(raw: string): Promise<void> {
|
||||
const signer = getActiveSigner();
|
||||
if (!signer) throw new Error("Reconnect your signer to share private chat key");
|
||||
const recipients = await getRecipients();
|
||||
const wraps = await Promise.all(
|
||||
recipients.map(async (recipientPubkey) => ({
|
||||
recipientPubkey,
|
||||
ciphertext: await signer.encrypt(recipientPubkey, raw),
|
||||
})),
|
||||
);
|
||||
await postChatKeyWraps({ wraps });
|
||||
}
|
||||
|
||||
async function importRoomKey(raw: string): Promise<CryptoKey> {
|
||||
if (cachedRoomKey && cachedRoomKeyRaw === raw) return cachedRoomKey;
|
||||
cachedRoomKey = await crypto.subtle.importKey("raw", unbase64(raw), { name: "AES-GCM" }, false, [
|
||||
"encrypt",
|
||||
"decrypt",
|
||||
]);
|
||||
cachedRoomKeyRaw = raw;
|
||||
return cachedRoomKey;
|
||||
}
|
||||
|
||||
function base64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function unbase64(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// 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";
|
||||
import type { Filter } from "nostr-tools/filter";
|
||||
import { SimplePool } from "nostr-tools/pool";
|
||||
import { readonly, ref } from "vue";
|
||||
|
||||
export type SignerKind = "extension" | "bunker";
|
||||
|
||||
export type Signer = {
|
||||
kind: SignerKind;
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>;
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>;
|
||||
};
|
||||
|
||||
const activeSigner = ref<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 SIGNER_SESSION_KEY = "gashboard.signer.session";
|
||||
const pool = new SimplePool();
|
||||
|
||||
export function getActiveSigner(): Signer | null {
|
||||
return activeSigner.value;
|
||||
}
|
||||
|
||||
export function hasActiveSigner(): boolean {
|
||||
return activeSigner.value !== null;
|
||||
}
|
||||
|
||||
export function clearSigner(): void {
|
||||
activeSigner.value = null;
|
||||
clearSavedSignerSession();
|
||||
}
|
||||
|
||||
export const activeSignerState = readonly(activeSigner);
|
||||
|
||||
export async function loginWithExtension(): Promise<string> {
|
||||
if (!window.nostr) {
|
||||
throw new Error("No Nostr signer available. Open Gashboard from Archipelago, or enable a NIP-07 browser extension.");
|
||||
}
|
||||
const ext = window.nostr;
|
||||
const pubkey = await ext.getPublicKey();
|
||||
activeSigner.value = {
|
||||
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,
|
||||
encrypt: async (target: string, plaintext: string) => {
|
||||
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
|
||||
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
|
||||
throw new Error("Your signer cannot encrypt private chat messages");
|
||||
},
|
||||
decrypt: async (sender: string, ciphertext: string) => {
|
||||
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
|
||||
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
|
||||
throw new Error("Your signer cannot decrypt private chat messages");
|
||||
},
|
||||
};
|
||||
saveSignerSession({ kind: "extension", pubkey });
|
||||
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();
|
||||
const remote = signer.remote || pubkey;
|
||||
activeSigner.value = {
|
||||
kind: "bunker",
|
||||
getPublicKey: async () => pubkey,
|
||||
signEvent: async (template: EventTemplate) =>
|
||||
(await signer.signEvent(template)) as NostrEvent,
|
||||
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
|
||||
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
|
||||
};
|
||||
saveSignerSession({
|
||||
kind: "bunker",
|
||||
key: Array.from(signer.signer.key),
|
||||
secret: signer.secret,
|
||||
relays: signer.relays,
|
||||
remote,
|
||||
pubkey,
|
||||
});
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
export async function loginWithRemoteApp(): Promise<string> {
|
||||
return connectRemoteApp({ openApp: true });
|
||||
}
|
||||
|
||||
export async function reconnectRemoteApp(): Promise<string> {
|
||||
clearSigner();
|
||||
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 ??
|
||||
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
|
||||
const PrivateKeySigner =
|
||||
(mod as { PrivateKeySigner?: any }).PrivateKeySigner ??
|
||||
((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner);
|
||||
if (!NostrConnectSigner || !PrivateKeySigner) {
|
||||
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: pending?.relays ?? NOSTR_CONNECT_RELAYS,
|
||||
signer: clientSigner,
|
||||
...(pending ? { secret: pending.secret } : {}),
|
||||
subscriptionMethod: subscribeToRelays,
|
||||
publishMethod: publishToRelays,
|
||||
});
|
||||
|
||||
const permissions = [
|
||||
...(NostrConnectSigner.buildSigningPermissions?.([27235, 4]) ?? ["sign_event:27235", "sign_event:4"]),
|
||||
"nip44_encrypt",
|
||||
"nip44_decrypt",
|
||||
];
|
||||
if (!pending) {
|
||||
savePendingRemoteLogin({
|
||||
key: Array.from(clientSigner.key),
|
||||
secret: signer.secret,
|
||||
relays: NOSTR_CONNECT_RELAYS,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
await signer.waitForSigner(abort.signal);
|
||||
if (!signer.remote) throw new Error("Remote signer did not complete the connection");
|
||||
const pubkey: string = await signer.getPublicKey();
|
||||
activeSigner.value = {
|
||||
kind: "bunker",
|
||||
getPublicKey: async () => pubkey,
|
||||
signEvent: async (template: EventTemplate) =>
|
||||
(await signer.signEvent(template)) as NostrEvent,
|
||||
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
|
||||
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
|
||||
};
|
||||
saveSignerSession({
|
||||
kind: "bunker",
|
||||
key: Array.from(clientSigner.key),
|
||||
secret: signer.secret,
|
||||
relays: signer.relays,
|
||||
remote: signer.remote,
|
||||
pubkey,
|
||||
});
|
||||
clearPendingRemoteLogin();
|
||||
return pubkey;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (!signer.isConnected) await signer.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreSavedSigner(): Promise<boolean> {
|
||||
if (activeSigner.value) return true;
|
||||
const saved = loadSavedSignerSession();
|
||||
if (!saved) return false;
|
||||
|
||||
if (saved.kind === "extension") {
|
||||
if (!window.nostr) return false;
|
||||
const ext = window.nostr;
|
||||
const pubkey = await ext.getPublicKey();
|
||||
if (saved.pubkey && saved.pubkey !== pubkey) {
|
||||
clearSavedSignerSession();
|
||||
return false;
|
||||
}
|
||||
activeSigner.value = {
|
||||
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,
|
||||
encrypt: async (target: string, plaintext: string) => {
|
||||
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
|
||||
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
|
||||
throw new Error("Your signer cannot encrypt private chat messages");
|
||||
},
|
||||
decrypt: async (sender: string, ciphertext: string) => {
|
||||
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
|
||||
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
|
||||
throw new Error("Your signer cannot decrypt private chat messages");
|
||||
},
|
||||
};
|
||||
saveSignerSession({ kind: "extension", pubkey });
|
||||
return true;
|
||||
}
|
||||
|
||||
const mod = await import("applesauce-signers");
|
||||
const NostrConnectSigner =
|
||||
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
|
||||
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
|
||||
const PrivateKeySigner =
|
||||
(mod as { PrivateKeySigner?: any }).PrivateKeySigner ??
|
||||
((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner);
|
||||
if (!NostrConnectSigner || !PrivateKeySigner) return false;
|
||||
|
||||
const signer = new NostrConnectSigner({
|
||||
relays: saved.relays,
|
||||
signer: new PrivateKeySigner(new Uint8Array(saved.key)),
|
||||
secret: saved.secret,
|
||||
remote: saved.remote,
|
||||
pubkey: saved.pubkey,
|
||||
subscriptionMethod: subscribeToRelays,
|
||||
publishMethod: publishToRelays,
|
||||
});
|
||||
activeSigner.value = {
|
||||
kind: "bunker",
|
||||
getPublicKey: async () => saved.pubkey,
|
||||
signEvent: async (template: EventTemplate) =>
|
||||
(await signer.signEvent(template)) as NostrEvent,
|
||||
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
|
||||
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
type PendingRemoteLogin = {
|
||||
key: number[];
|
||||
secret: string;
|
||||
relays: string[];
|
||||
};
|
||||
|
||||
type SavedSignerSession =
|
||||
| {
|
||||
kind: "extension";
|
||||
pubkey: string;
|
||||
}
|
||||
| {
|
||||
kind: "bunker";
|
||||
key: number[];
|
||||
secret: string;
|
||||
relays: string[];
|
||||
remote: string;
|
||||
pubkey: 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 loadSavedSignerSession(): SavedSignerSession | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SIGNER_SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as SavedSignerSession;
|
||||
if (parsed.kind === "extension" && parsed.pubkey) return parsed;
|
||||
if (
|
||||
parsed.kind === "bunker" &&
|
||||
Array.isArray(parsed.key) &&
|
||||
parsed.secret &&
|
||||
Array.isArray(parsed.relays) &&
|
||||
parsed.remote &&
|
||||
parsed.pubkey
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSignerSession(session: SavedSignerSession): void {
|
||||
localStorage.setItem(SIGNER_SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
function clearSavedSignerSession(): void {
|
||||
localStorage.removeItem(SIGNER_SESSION_KEY);
|
||||
}
|
||||
|
||||
function withCallback(uri: string): string {
|
||||
const separator = uri.includes("?") ? "&" : "?";
|
||||
return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`;
|
||||
}
|
||||
|
||||
function openSignerApp(uri: string): void {
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
if (isMobile) {
|
||||
window.location.href = uri;
|
||||
return;
|
||||
}
|
||||
const opened = window.open(uri, "_blank", "noopener,noreferrer");
|
||||
if (!opened) window.location.href = uri;
|
||||
}
|
||||
|
||||
function subscribeToRelays(relays: string[], filters: Filter[]): AsyncIterable<NostrEvent | string> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
const queue: Array<NostrEvent | string> = [];
|
||||
let wake: (() => void) | null = null;
|
||||
let closed = false;
|
||||
const sub = pool.subscribeMany(relays, filters, {
|
||||
onevent(event) {
|
||||
queue.push(event as NostrEvent);
|
||||
wake?.();
|
||||
},
|
||||
onclose(reasons) {
|
||||
for (const reason of reasons) queue.push(reason);
|
||||
closed = true;
|
||||
wake?.();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
async next(): Promise<IteratorResult<NostrEvent | string>> {
|
||||
while (!queue.length && !closed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = null;
|
||||
}
|
||||
const value = queue.shift();
|
||||
if (value) return { value, done: false };
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
async return(): Promise<IteratorResult<NostrEvent | string>> {
|
||||
closed = true;
|
||||
sub.close();
|
||||
wake?.();
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function publishToRelays(relays: string[], event: NostrEvent): Promise<void> {
|
||||
const results = await Promise.allSettled(pool.publish(relays, event));
|
||||
if (!results.some((r) => r.status === "fulfilled")) {
|
||||
throw new Error("Could not publish Nostr Connect request to relay");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 restoringSigner = ref(false);
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value);
|
||||
const hasActiveSigner = computed(() => signer.activeSignerState.value !== null);
|
||||
|
||||
if (token.value) void restoreSavedSigner();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginRemoteApp(): Promise<void> {
|
||||
error.value = null;
|
||||
busy.value = true;
|
||||
try {
|
||||
await signer.loginWithRemoteApp();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectRemoteApp(): Promise<void> {
|
||||
error.value = null;
|
||||
busy.value = true;
|
||||
try {
|
||||
await signer.reconnectRemoteApp();
|
||||
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 reconnect failed";
|
||||
throw e;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
token.value = null;
|
||||
npub.value = null;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
async function restoreSavedSigner(): Promise<boolean> {
|
||||
if (hasActiveSigner.value) return true;
|
||||
restoringSigner.value = true;
|
||||
try {
|
||||
return await signer.restoreSavedSigner();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Could not restore signer";
|
||||
return false;
|
||||
} finally {
|
||||
restoringSigner.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
npub,
|
||||
token,
|
||||
error,
|
||||
busy,
|
||||
restoringSigner,
|
||||
isLoggedIn,
|
||||
hasActiveSigner,
|
||||
loginExtension,
|
||||
loginBunker,
|
||||
loginRemoteApp,
|
||||
reconnectRemoteApp,
|
||||
resumeRemoteAppLogin,
|
||||
restoreSavedSigner,
|
||||
hasPendingRemoteAppLogin: signer.hasPendingRemoteAppLogin,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import * as api from "../services/api";
|
||||
import type { DatumSnapshot, HistoryPoint } from "../types";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_KEY = "gashboard.history.v1";
|
||||
const MAX_HISTORY_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_HISTORY_POINTS = 18_000;
|
||||
|
||||
function loadHistory(): HistoryPoint[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as HistoryPoint[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
const minT = Date.now() - MAX_HISTORY_AGE_MS;
|
||||
return parsed.filter((p) => Number.isFinite(p.t) && p.t >= minT);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function asHistoryPoint(snap: DatumSnapshot): HistoryPoint {
|
||||
const miners: Record<string, number> = {};
|
||||
const minerWork: Record<string, number> = {};
|
||||
const minerShares: Record<string, number> = {};
|
||||
const minerStatus: Record<string, DatumSnapshot["miners"][number]["status"]> = {};
|
||||
for (const miner of snap.miners) {
|
||||
const key = miner.authUsername || miner.remoteHost || miner.nickname;
|
||||
miners[key] = miner.hashrateThs;
|
||||
minerWork[key] = miner.diffAcceptedSum;
|
||||
minerShares[key] = miner.diffAcceptedCount;
|
||||
minerStatus[key] = miner.status;
|
||||
}
|
||||
return {
|
||||
t: snap.fetchedAt,
|
||||
ths: snap.pool.combinedHashrateThs,
|
||||
accepted: snap.pool.sharesAccepted,
|
||||
rejected: snap.pool.sharesRejected,
|
||||
connections: snap.pool.totalConnections,
|
||||
subscriptions: snap.pool.totalSubscriptions,
|
||||
height: snap.job.blockHeight,
|
||||
miners,
|
||||
minerWork,
|
||||
minerShares,
|
||||
minerStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export const useStatsStore = defineStore("stats", () => {
|
||||
const snapshot = ref<DatumSnapshot | null>(null);
|
||||
const history = ref<HistoryPoint[]>(loadHistory());
|
||||
const error = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
let timer: number | null = null;
|
||||
let persistTimer: number | null = null;
|
||||
|
||||
function remember(snap: DatumSnapshot): void {
|
||||
if (!snap.ok) return;
|
||||
const last = history.value.at(-1);
|
||||
if (last?.t === snap.fetchedAt) return;
|
||||
history.value.push(asHistoryPoint(snap));
|
||||
const minT = Date.now() - MAX_HISTORY_AGE_MS;
|
||||
history.value = history.value.filter((p) => p.t >= minT).slice(-MAX_HISTORY_POINTS);
|
||||
if (persistTimer !== null) window.clearTimeout(persistTimer);
|
||||
persistTimer = window.setTimeout(() => {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value));
|
||||
persistTimer = null;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function fetchOnce(): Promise<void> {
|
||||
try {
|
||||
loading.value = true;
|
||||
const next = await api.fetchSnapshot();
|
||||
snapshot.value = next;
|
||||
remember(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, history, error, loading, fetchOnce, start, stop };
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { oneIn } from "./format";
|
||||
|
||||
// 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": "desk ornament with a fan. occasionally impersonates infrastructure.",
|
||||
"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) =>
|
||||
`today: ${formatPct(oddsPerDay)}. A rounding error wearing a cape.`,
|
||||
(oddsPerDay: number) =>
|
||||
`expected wait: ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. Your descendants' descendants will still be checking the fan noise.`,
|
||||
() =>
|
||||
`the math says no in every language, including ones we haven't invented yet.`,
|
||||
(oddsPerDay: number) =>
|
||||
`versus lightning today: ${ratioVsLightning(oddsPerDay)}x. Even the sky has a better business plan.`,
|
||||
() =>
|
||||
`four little heaters bullying SHA-256 and calling it a retirement strategy.`,
|
||||
() =>
|
||||
`every share is proof of work. Not useful work. Let's not get carried away.`,
|
||||
() =>
|
||||
`solo mining: where hope goes to become server logs.`,
|
||||
() =>
|
||||
`this is not financial advice. It is barely electrical advice.`,
|
||||
() =>
|
||||
`the fiat system is shaking, but not because of this. This is mostly fan noise.`,
|
||||
() =>
|
||||
`somewhere a central banker felt nothing at all.`,
|
||||
() =>
|
||||
`if this finds a block, check the logs, the stars, and whoever touched the thermostat.`,
|
||||
() =>
|
||||
`the orange pill was supposed to be freedom, not watching a desk fan beg for destiny.`,
|
||||
() =>
|
||||
`your odds are non-zero, which is how all terrible ideas get funding.`,
|
||||
() =>
|
||||
`the network difficulty looked at this rig and filed a noise complaint.`,
|
||||
() =>
|
||||
`proof of work? More like proof of warm.`,
|
||||
() =>
|
||||
`fiat dies slowly. This block attempt dies every ten minutes.`,
|
||||
() =>
|
||||
`the hash is strong. The probability is in witness protection.`,
|
||||
() =>
|
||||
`the IMF has not yet issued a statement about your 0.000000whatever percent chance.`,
|
||||
() =>
|
||||
`solo mining is just buying a lottery ticket that also heats the hallway.`,
|
||||
() =>
|
||||
`the block reward remains technically available, like buried treasure on Mars.`,
|
||||
() =>
|
||||
`every rejected share is a tiny central bank meeting with worse catering.`,
|
||||
() =>
|
||||
`some call it sovereign mining. Others call it an elaborate way to run a heater.`,
|
||||
() =>
|
||||
`the machines are awake. The odds are legally dead.`,
|
||||
() =>
|
||||
`if math had a face, it would be avoiding eye contact.`,
|
||||
() =>
|
||||
`hash harder, little toaster. The empire will not dismantle itself.`,
|
||||
() =>
|
||||
`Bitcoin is permissionless, including permission to embarrass yourself electrically.`,
|
||||
() =>
|
||||
`your miners are attacking fiat with the force of a damp receipt.`,
|
||||
() =>
|
||||
`the conspiracy is real: the big pools have more ASICs than your shelf.`,
|
||||
() =>
|
||||
`this is how monetary revolution sounds when it has a 40mm fan.`,
|
||||
() =>
|
||||
`the Cantillon effect remains undefeated by the cupboard.`,
|
||||
() =>
|
||||
`the nonce search continues. So does the comedy.`,
|
||||
() =>
|
||||
`somewhere, Satoshi is either proud or changing the Wi-Fi password.`,
|
||||
() =>
|
||||
`the chance is small enough to qualify as abstract art.`,
|
||||
() =>
|
||||
`mining a block today would be less prediction, more paranormal incident.`,
|
||||
() =>
|
||||
`these rigs are not decentralizing power. They are decentralizing warmth.`,
|
||||
() =>
|
||||
`the mempool sees you. It is trying not to laugh.`,
|
||||
() =>
|
||||
`fiat printers go brrr. Your fan goes whrrr. Guess who has better margins.`,
|
||||
() =>
|
||||
`today's block odds have been forwarded to the Ministry of Delusion.`,
|
||||
() =>
|
||||
`the network hashrate is a dragon. You brought teaspoons.`,
|
||||
() =>
|
||||
`this is sovereign self-custody of disappointment.`,
|
||||
() =>
|
||||
`if a block appears, assume divine intervention or a rounding bug.`,
|
||||
() =>
|
||||
`monetary debasement is tragic. This rig's odds are slapstick.`,
|
||||
() =>
|
||||
`your ASICs are yelling at SHA-256 through a letterbox.`,
|
||||
() =>
|
||||
`the pools have warehouses. You have vibes and a power strip.`,
|
||||
() =>
|
||||
`a beautiful act of rebellion, if rebellion paid in almost nothing.`,
|
||||
() =>
|
||||
`the block subsidy is safe from you for now.`,
|
||||
() =>
|
||||
`hashing like this is how you whisper at a volcano.`,
|
||||
() =>
|
||||
`the odds are dark, but at least the room isn't cold.`,
|
||||
() =>
|
||||
`fiat friends buy heaters. You bought statistically complicated heaters.`,
|
||||
() =>
|
||||
`the nonce lottery has reviewed your application and placed it under a very large pile.`,
|
||||
() =>
|
||||
`this is not a get-rich-quick scheme. It is barely a get-warm-slowly scheme.`,
|
||||
() =>
|
||||
`the global banking cartel remains untroubled by this particular fan curve.`,
|
||||
() =>
|
||||
`somebody said "be your own bank" and now the shelf is making whale noises.`,
|
||||
() =>
|
||||
`the blockchain is immutable. So is the humiliation.`,
|
||||
() =>
|
||||
`your chance is smaller than a politician's apology and twice as expensive to heat.`,
|
||||
() =>
|
||||
`the rig believes. The spreadsheet has concerns.`,
|
||||
() =>
|
||||
`mining solo is what happens when optimism discovers electricity.`,
|
||||
() =>
|
||||
`the odds are so low they qualify as a privacy feature.`,
|
||||
() =>
|
||||
`keep stacking sats. These machines are mostly stacking anecdotes.`,
|
||||
() =>
|
||||
`the revolution will be decentralized, but apparently not profitable today.`,
|
||||
];
|
||||
|
||||
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 oneIn(p);
|
||||
}
|
||||
|
||||
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 `about ${oneIn(1 / r)} as likely`;
|
||||
if (r < 1) return r.toFixed(3);
|
||||
if (r < 1000) return r.toFixed(1);
|
||||
return `${(r / 1000).toFixed(1)}K`;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* 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(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);
|
||||
color: var(--fg-0);
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
export type MinerStat = {
|
||||
authUsername: string;
|
||||
remoteHost: string;
|
||||
nickname: string;
|
||||
model: string;
|
||||
location: string;
|
||||
expectedHashrateThs: number;
|
||||
watts: number;
|
||||
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";
|
||||
contributionAcceptedWork?: number;
|
||||
contributionAcceptedShares?: number;
|
||||
contributionPct?: number;
|
||||
contributionFirstSeenAt?: number;
|
||||
contributionLastSeenAt?: number;
|
||||
currentlyConnected?: boolean;
|
||||
};
|
||||
|
||||
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 NetworkStat = {
|
||||
blockHeight: number;
|
||||
hashrateEh: number;
|
||||
difficulty: number;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type DatumSnapshot = {
|
||||
ok: boolean;
|
||||
fetchedAt: number;
|
||||
pool: PoolStat;
|
||||
job: CurrentJob;
|
||||
network: NetworkStat;
|
||||
miners: MinerStat[];
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
export type HistoryPoint = {
|
||||
t: number;
|
||||
ths: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
connections: number;
|
||||
subscriptions: number;
|
||||
height: number;
|
||||
miners: Record<string, number>;
|
||||
minerWork: Record<string, number>;
|
||||
minerShares: Record<string, number>;
|
||||
minerStatus: Record<string, MinerStat["status"]>;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
token: string;
|
||||
npub: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type ChatSnapshot = {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
replyToId: string;
|
||||
replyToPubkey: string;
|
||||
}>;
|
||||
reactions: Array<{
|
||||
id: string;
|
||||
pubkey: string;
|
||||
messageId: string;
|
||||
content: string;
|
||||
createdAt: number;
|
||||
}>;
|
||||
keyWraps: Array<{
|
||||
recipientPubkey: string;
|
||||
senderPubkey: string;
|
||||
ciphertext: string;
|
||||
updatedAt: 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;
|
||||
}>;
|
||||
nip44?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>;
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>;
|
||||
};
|
||||
nip04?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>;
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { loadToken } from "../services/api";
|
||||
|
||||
type Member = { npub: string; role: "owner" | "viewer" };
|
||||
const members = ref<Member[]>([]);
|
||||
const owner = ref(false);
|
||||
const loaded = ref(false);
|
||||
const busy = ref(false);
|
||||
const npub = ref("");
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
|
||||
async function request(method = "GET", key = ""): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
const token = loadToken()?.token;
|
||||
const res = await fetch(`/api/access${method === "DELETE" ? `/${encodeURIComponent(key)}` : ""}`, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${token ?? ""}`, "content-type": "application/json" },
|
||||
...(method === "POST" ? { body: JSON.stringify({ npub: key }) } : {}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error?.message ?? "Could not update access. Please sign in again.");
|
||||
owner.value = data.isOwner;
|
||||
members.value = data.members;
|
||||
loaded.value = true;
|
||||
if (method === "POST") { npub.value = ""; notice.value = "Viewer added. They can now sign in with that Nostr identity."; }
|
||||
if (method === "DELETE") notice.value = "Viewer removed. Existing sessions no longer have access.";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Could not load access list.";
|
||||
} finally { busy.value = false; }
|
||||
}
|
||||
|
||||
onMounted(() => { void request(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel access">
|
||||
<h1 class="glow-cyan">Your mining crew</h1>
|
||||
<p class="muted">Choose who can see your fleet, shares, and dashboard chat.</p>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<p v-if="notice" role="status">{{ notice }}</p>
|
||||
<p v-if="!loaded && !error">Loading access list…</p>
|
||||
<p v-if="loaded && !owner">Ask a node owner to add or remove people. Your account has viewer access.</p>
|
||||
<template v-if="loaded && owner">
|
||||
<form @submit.prevent="request('POST', npub.trim())">
|
||||
<label for="viewer-npub">Invite a viewer by Nostr public key</label>
|
||||
<div class="invite">
|
||||
<input id="viewer-npub" v-model="npub" placeholder="npub1…" autocomplete="off" spellcheck="false" required :disabled="busy" />
|
||||
<button class="primary" type="submit" :disabled="busy || !npub.trim()">Add viewer</button>
|
||||
</div>
|
||||
<small class="muted">Viewers can follow mining and join chat. Only node owners can change this list.</small>
|
||||
</form>
|
||||
<ul>
|
||||
<li v-for="member in members" :key="member.npub">
|
||||
<div><span class="role">{{ member.role }}</span><code>{{ member.npub }}</code></div>
|
||||
<button v-if="member.role === 'viewer'" :disabled="busy" :aria-label="`Remove ${member.npub}`" @click="request('DELETE', member.npub)">Remove</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">Owners come from Archipelago identities. Restart Gashboard after changing node identities. Invited viewers stay on this list across restarts.</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.access { max-width: 880px; margin: 0 auto; }
|
||||
h1 { margin-top: 0; }
|
||||
form { margin: 28px 0; }
|
||||
label { display: block; margin-bottom: 12px; }
|
||||
.invite { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||
input { flex: 1; min-width: 0; }
|
||||
ul { padding: 0; list-style: none; }
|
||||
li { display: flex; align-items: center; gap: 16px; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||||
li > div { flex: 1; min-width: 0; }
|
||||
code { display: block; overflow-wrap: anywhere; font-size: 12px; margin-top: 8px; }
|
||||
.role { color: var(--neon-cyan); text-transform: uppercase; }
|
||||
[role="alert"] { color: var(--neon-red); }
|
||||
[role="status"] { color: var(--neon-cyan); }
|
||||
@media (max-width: 520px) { .invite { flex-direction: column; } }
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<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";
|
||||
import Shameboard from "../components/Shameboard.vue";
|
||||
|
||||
const stats = useStatsStore();
|
||||
|
||||
onMounted(() => stats.start());
|
||||
onUnmounted(() => stats.stop());
|
||||
|
||||
const totalThs = computed(() => stats.snapshot?.pool.combinedHashrateThs ?? 0);
|
||||
const networkEh = computed(() => stats.snapshot?.network.hashrateEh ?? 0);
|
||||
const miners = computed(() => stats.snapshot?.miners ?? []);
|
||||
const errorMsg = computed(() => stats.error);
|
||||
const upstreamErr = computed(() => stats.snapshot?.error);
|
||||
|
||||
const bestWorkByMiner = computed(() => {
|
||||
const out: Record<string, number> = {};
|
||||
for (let i = 1; i < stats.history.length; i += 1) {
|
||||
const prev = stats.history[i - 1];
|
||||
const next = stats.history[i];
|
||||
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
|
||||
for (const [key, work] of Object.entries(next.minerWork)) {
|
||||
const shareDelta = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
|
||||
const workDelta = work - (prev.minerWork[key] ?? work);
|
||||
if (shareDelta <= 0 || workDelta <= 0) continue;
|
||||
out[key] = Math.max(out[key] ?? 0, workDelta / shareDelta);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const poolBestWork = computed(() => Math.max(0, ...Object.values(bestWorkByMiner.value)));
|
||||
const contributionRows = computed(() => {
|
||||
return miners.value
|
||||
.filter((m) => (m.contributionAcceptedWork ?? m.diffAcceptedSum) > 0)
|
||||
.map((m) => ({
|
||||
key: minerKey(m),
|
||||
name: m.nickname,
|
||||
work: m.contributionAcceptedWork ?? m.diffAcceptedSum,
|
||||
pct: m.contributionPct ?? 0,
|
||||
connected: m.currentlyConnected ?? true,
|
||||
}))
|
||||
.sort((a, b) => b.work - a.work);
|
||||
});
|
||||
const boomerHeater = {
|
||||
authUsername: "fiat.heat",
|
||||
remoteHost: "wall-socket",
|
||||
nickname: "Boomer Heater",
|
||||
model: "Legacy Resistance Heater",
|
||||
location: "The Past",
|
||||
expectedHashrateThs: 0,
|
||||
watts: 2000,
|
||||
hashrateThs: 0,
|
||||
hashrateAgeS: null,
|
||||
lastShareAgeS: null,
|
||||
diffAcceptedSum: 0,
|
||||
diffAcceptedCount: 0,
|
||||
diffRejectedSum: 0,
|
||||
diffRejectedCount: 0,
|
||||
rejectPct: 0,
|
||||
vdiff: 0,
|
||||
userAgent: "CentralHeating/fiat-only",
|
||||
subscribed: false,
|
||||
status: "idle",
|
||||
} as const;
|
||||
|
||||
function minerKey(m: { nickname: string; authUsername: string; remoteHost: string }): string {
|
||||
return m.authUsername || m.remoteHost || m.nickname;
|
||||
}
|
||||
</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" />
|
||||
|
||||
<section class="best panel">
|
||||
<div>
|
||||
<div class="label">best accepted calculation observed</div>
|
||||
<strong class="glow-amber">
|
||||
{{ poolBestWork > 0 ? poolBestWork.toLocaleString(undefined, { maximumFractionDigits: 2 }) : "collecting" }}
|
||||
</strong>
|
||||
</div>
|
||||
<span class="muted">derived from accepted difficulty jumps while this browser has history</span>
|
||||
</section>
|
||||
|
||||
<section class="split panel">
|
||||
<div class="split-head">
|
||||
<div>
|
||||
<div class="label">block reward split</div>
|
||||
<strong class="glow-green">accepted-work ledger</strong>
|
||||
</div>
|
||||
<span class="muted">persisted after this deploy</span>
|
||||
</div>
|
||||
<div v-if="contributionRows.length" class="split-rows">
|
||||
<div v-for="row in contributionRows" :key="row.key" class="split-row">
|
||||
<span>
|
||||
{{ row.name }}
|
||||
<small v-if="!row.connected" class="muted">offline</small>
|
||||
</span>
|
||||
<strong>{{ row.pct.toFixed(2) }}%</strong>
|
||||
<em class="muted">{{ row.work.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">waiting for accepted work</div>
|
||||
</section>
|
||||
|
||||
<div class="grid-row">
|
||||
<LotteryWidget :total-ths="totalThs" :network-eh="networkEh" />
|
||||
<ShareTicker :snapshot="stats.snapshot" />
|
||||
</div>
|
||||
|
||||
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
|
||||
|
||||
<div class="miners">
|
||||
<MinerCard
|
||||
v-for="m in miners"
|
||||
:key="m.authUsername || m.nickname"
|
||||
:miner="m"
|
||||
:best-work="bestWorkByMiner[minerKey(m)]"
|
||||
/>
|
||||
<MinerCard
|
||||
key="boomer-heater"
|
||||
:miner="boomerHeater"
|
||||
:best-work="0"
|
||||
/>
|
||||
<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: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.best {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.best strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.best span {
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.split {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.split-head,
|
||||
.split-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.split-head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.split-rows {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.split-row {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
}
|
||||
.split-row small {
|
||||
margin-left: 8px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.split-row em {
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
}
|
||||
.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; }
|
||||
.best {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.best span {
|
||||
text-align: left;
|
||||
}
|
||||
.split-head,
|
||||
.split-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.split-head > span,
|
||||
.split-row em {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,447 @@
|
||||
<script setup lang="ts">
|
||||
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();
|
||||
|
||||
onMounted(() => stats.start());
|
||||
onUnmounted(() => stats.stop());
|
||||
|
||||
const ranges = [
|
||||
{ label: "15m", ms: 15 * 60 * 1000 },
|
||||
{ label: "1h", ms: 60 * 60 * 1000 },
|
||||
{ label: "6h", ms: 6 * 60 * 60 * 1000 },
|
||||
{ label: "24h", ms: 24 * 60 * 60 * 1000 },
|
||||
] as const;
|
||||
|
||||
const selectedRange = ref<(typeof ranges)[number]["label"]>("1h");
|
||||
const activeRange = computed(() => ranges.find((r) => r.label === selectedRange.value) ?? ranges[1]);
|
||||
const now = computed(() => stats.snapshot?.fetchedAt ?? Date.now());
|
||||
const points = computed(() => {
|
||||
const minT = now.value - activeRange.value.ms;
|
||||
const filtered = stats.history.filter((p) => p.t >= minT);
|
||||
return filtered.length > 0 ? filtered : stats.history;
|
||||
});
|
||||
|
||||
const current = computed(() => stats.snapshot);
|
||||
const currentThs = computed(() => current.value?.pool.combinedHashrateThs ?? 0);
|
||||
const activeMiners = computed(() => current.value?.miners.filter((m) => m.status === "hashing").length ?? 0);
|
||||
const windowLabel = computed(() => {
|
||||
const first = points.value[0];
|
||||
const last = points.value.at(-1);
|
||||
if (!first || !last) return "collecting";
|
||||
const minutes = Math.max(0, Math.round((last.t - first.t) / 60000));
|
||||
if (minutes < 60) return `${minutes}m captured`;
|
||||
return `${(minutes / 60).toFixed(1)}h captured`;
|
||||
});
|
||||
|
||||
const deltas = computed(() => {
|
||||
const first = points.value[0];
|
||||
const last = points.value.at(-1);
|
||||
if (!first || !last) return { accepted: 0, rejected: 0, total: 0, rejectPct: 0 };
|
||||
const accepted = Math.max(0, last.accepted - first.accepted);
|
||||
const rejected = Math.max(0, last.rejected - first.rejected);
|
||||
const total = accepted + rejected;
|
||||
return { accepted, rejected, total, rejectPct: total > 0 ? (rejected / total) * 100 : 0 };
|
||||
});
|
||||
|
||||
const avgThs = computed(() => {
|
||||
if (!points.value.length) return 0;
|
||||
return points.value.reduce((sum, p) => sum + p.ths, 0) / points.value.length;
|
||||
});
|
||||
|
||||
const highLow = computed(() => {
|
||||
if (!points.value.length) return { high: 0, low: 0 };
|
||||
return {
|
||||
high: Math.max(...points.value.map((p) => p.ths)),
|
||||
low: Math.min(...points.value.map((p) => p.ths)),
|
||||
};
|
||||
});
|
||||
|
||||
const shareRate = computed(() => {
|
||||
const first = points.value[0];
|
||||
const last = points.value.at(-1);
|
||||
if (!first || !last || last.t <= first.t) return 0;
|
||||
return deltas.value.accepted / ((last.t - first.t) / 3600000);
|
||||
});
|
||||
|
||||
const hashratePath = computed(() => linePath(points.value.map((p) => ({ x: p.t, y: p.ths })), 460, 190));
|
||||
const shareBars = computed(() => buildShareBuckets(points.value, activeRange.value.ms));
|
||||
const minerRows = computed(() => {
|
||||
const latest = current.value?.miners ?? [];
|
||||
return latest
|
||||
.map((m) => {
|
||||
const key = m.authUsername || m.remoteHost || m.nickname;
|
||||
const average = averageMinerHashrate(points.value, key);
|
||||
const bestWork = bestObservedWork(stats.history, key);
|
||||
const pct = currentThs.value > 0 ? (m.hashrateThs / currentThs.value) * 100 : 0;
|
||||
return { key, name: m.nickname, model: m.model, current: m.hashrateThs, average, bestWork, pct, status: m.status };
|
||||
})
|
||||
.sort((a, b) => b.current - a.current);
|
||||
});
|
||||
|
||||
function linePath(items: { x: number; y: number }[], width: number, height: number): string {
|
||||
if (items.length < 2) return "";
|
||||
const first = items[0];
|
||||
if (!first) return "";
|
||||
const minX = first.x;
|
||||
const maxX = items.at(-1)?.x ?? minX + 1;
|
||||
const minY = Math.min(...items.map((i) => i.y), 0);
|
||||
const maxY = Math.max(...items.map((i) => i.y), 1);
|
||||
const spanX = Math.max(1, maxX - minX);
|
||||
const spanY = Math.max(0.001, maxY - minY);
|
||||
return items
|
||||
.map((item, idx) => {
|
||||
const x = ((item.x - minX) / spanX) * width;
|
||||
const y = height - ((item.y - minY) / spanY) * height;
|
||||
return `${idx === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildShareBuckets(history: HistoryPoint[], rangeMs: number): { x: number; accepted: number; rejected: number; h: number }[] {
|
||||
if (history.length < 2) return [];
|
||||
const bucketCount = rangeMs <= 60 * 60 * 1000 ? 30 : 48;
|
||||
const first = history[0];
|
||||
if (!first) return [];
|
||||
const start = first.t;
|
||||
const end = history.at(-1)?.t ?? start + 1;
|
||||
const span = Math.max(1, end - start);
|
||||
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ x: i, accepted: 0, rejected: 0, h: 0 }));
|
||||
for (let i = 1; i < history.length; i += 1) {
|
||||
const prev = history[i - 1];
|
||||
const next = history[i];
|
||||
if (!prev || !next) continue;
|
||||
const idx = Math.min(bucketCount - 1, Math.max(0, Math.floor(((next.t - start) / span) * bucketCount)));
|
||||
const bucket = buckets[idx];
|
||||
if (!bucket) continue;
|
||||
bucket.accepted += Math.max(0, next.accepted - prev.accepted);
|
||||
bucket.rejected += Math.max(0, next.rejected - prev.rejected);
|
||||
}
|
||||
const max = Math.max(...buckets.map((b) => b.accepted + b.rejected), 1);
|
||||
return buckets.map((b) => ({ ...b, h: ((b.accepted + b.rejected) / max) * 72 }));
|
||||
}
|
||||
|
||||
function averageMinerHashrate(history: HistoryPoint[], key: string): number {
|
||||
const values = history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
|
||||
if (!values.length) return 0;
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function bestObservedWork(history: HistoryPoint[], key: string): number {
|
||||
let best = 0;
|
||||
for (let i = 1; i < history.length; i += 1) {
|
||||
const prev = history[i - 1];
|
||||
const next = history[i];
|
||||
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
|
||||
const shareDelta = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
|
||||
const workDelta = (next.minerWork[key] ?? 0) - (prev.minerWork[key] ?? 0);
|
||||
if (shareDelta > 0 && workDelta > 0) best = Math.max(best, workDelta / shareDelta);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function fmt(n: number, digits = 2): string {
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: digits, minimumFractionDigits: digits });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="graph-shell">
|
||||
<header class="graph-head">
|
||||
<div>
|
||||
<div class="label">graph view</div>
|
||||
<h1 class="glow-cyan">Hashrate telemetry</h1>
|
||||
</div>
|
||||
<div class="range-tabs">
|
||||
<button
|
||||
v-for="range in ranges"
|
||||
:key="range.label"
|
||||
:class="{ active: selectedRange === range.label }"
|
||||
@click="selectedRange = range.label"
|
||||
>
|
||||
{{ range.label }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MinerRace :snapshot="stats.snapshot" :history="stats.history" />
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric panel">
|
||||
<div class="label">now</div>
|
||||
<strong class="glow-cyan">{{ fmt(currentThs) }}</strong>
|
||||
<span>Th/s</span>
|
||||
</div>
|
||||
<div class="metric panel">
|
||||
<div class="label">window avg</div>
|
||||
<strong>{{ fmt(avgThs) }}</strong>
|
||||
<span>Th/s · {{ windowLabel }}</span>
|
||||
</div>
|
||||
<div class="metric panel">
|
||||
<div class="label">accepted rate</div>
|
||||
<strong class="glow-green">{{ fmt(shareRate, 1) }}</strong>
|
||||
<span>shares/hour</span>
|
||||
</div>
|
||||
<div class="metric panel">
|
||||
<div class="label">rejects</div>
|
||||
<strong :class="deltas.rejectPct > 3 ? 'glow-red' : ''">{{ fmt(deltas.rejectPct, 2) }}%</strong>
|
||||
<span>{{ deltas.rejected.toLocaleString() }} / {{ deltas.total.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="metric panel">
|
||||
<div class="label">active miners</div>
|
||||
<strong>{{ activeMiners }}</strong>
|
||||
<span>{{ current?.pool.totalSubscriptions ?? 0 }} subscriptions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-card wide">
|
||||
<div class="chart-title">
|
||||
<div>
|
||||
<div class="label">combined hashrate</div>
|
||||
<strong>{{ fmt(highLow.low) }} → {{ fmt(highLow.high) }} Th/s</strong>
|
||||
</div>
|
||||
<span class="muted">{{ points.length.toLocaleString() }} samples</span>
|
||||
</div>
|
||||
<svg class="line-chart" viewBox="0 0 460 190" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="hashFill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--neon-cyan)" stop-opacity="0.22" />
|
||||
<stop offset="100%" stop-color="var(--neon-cyan)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path v-if="hashratePath" :d="`${hashratePath} L460,190 L0,190 Z`" fill="url(#hashFill)" />
|
||||
<path v-if="hashratePath" :d="hashratePath" class="hash-line" />
|
||||
<text v-else x="230" y="96" text-anchor="middle">collecting samples</text>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-card">
|
||||
<div class="chart-title">
|
||||
<div>
|
||||
<div class="label">share flow</div>
|
||||
<strong>{{ deltas.accepted.toLocaleString() }} accepted</strong>
|
||||
</div>
|
||||
<span class="muted">{{ deltas.rejected.toLocaleString() }} rejected</span>
|
||||
</div>
|
||||
<div class="bars">
|
||||
<div v-for="bar in shareBars" :key="bar.x" class="bar-track">
|
||||
<span class="bar accepted" :style="{ height: `${bar.h}px` }" />
|
||||
<span
|
||||
v-if="bar.rejected"
|
||||
class="bar rejected"
|
||||
:style="{ height: `${Math.max(4, (bar.rejected / Math.max(1, bar.accepted + bar.rejected)) * bar.h)}px` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-card">
|
||||
<div class="chart-title">
|
||||
<div>
|
||||
<div class="label">miner mix</div>
|
||||
<strong>{{ minerRows.length }} devices</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="miner-mix">
|
||||
<div v-for="miner in minerRows" :key="miner.key" class="miner-row">
|
||||
<div class="miner-meta">
|
||||
<span :class="['status-dot', miner.status]" />
|
||||
<strong>{{ miner.name }}</strong>
|
||||
<span class="muted">{{ fmt(miner.current) }} Th/s</span>
|
||||
</div>
|
||||
<div class="mix-track">
|
||||
<span :style="{ width: `${Math.min(100, miner.pct)}%` }" />
|
||||
</div>
|
||||
<div class="muted avg">
|
||||
avg {{ fmt(miner.average) }} Th/s · best {{ miner.bestWork > 0 ? fmt(miner.bestWork) : "collecting" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.graph-head,
|
||||
.chart-title,
|
||||
.miner-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.range-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.range-tabs button {
|
||||
min-width: 52px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.range-tabs button.active {
|
||||
border-color: var(--neon-magenta);
|
||||
color: var(--neon-magenta);
|
||||
box-shadow: var(--shadow-magenta);
|
||||
}
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.metric {
|
||||
min-height: 104px;
|
||||
}
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.metric span {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--fg-2);
|
||||
font-size: 11px;
|
||||
}
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.chart-card {
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart-card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.line-chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-top: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background:
|
||||
linear-gradient(to bottom, transparent 24%, rgba(255,255,255,0.04) 25%, transparent 26%),
|
||||
linear-gradient(to bottom, transparent 49%, rgba(255,255,255,0.04) 50%, transparent 51%),
|
||||
linear-gradient(to bottom, transparent 74%, rgba(255,255,255,0.04) 75%, transparent 76%);
|
||||
}
|
||||
.hash-line {
|
||||
fill: none;
|
||||
stroke: var(--neon-cyan);
|
||||
stroke-width: 2.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
filter: drop-shadow(0 0 5px rgba(41, 255, 230, 0.45));
|
||||
}
|
||||
.line-chart text {
|
||||
fill: var(--fg-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.bars {
|
||||
height: 170px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(48, 1fr);
|
||||
gap: 3px;
|
||||
align-items: end;
|
||||
margin-top: 34px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.bar-track {
|
||||
height: 90px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
}
|
||||
.bar {
|
||||
width: 100%;
|
||||
min-height: 2px;
|
||||
display: block;
|
||||
}
|
||||
.bar.accepted {
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 8px rgba(108, 255, 140, 0.25);
|
||||
}
|
||||
.bar.rejected {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
background: var(--neon-red);
|
||||
}
|
||||
.miner-mix {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.miner-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--fg-2);
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
.status-dot.hashing { background: var(--neon-green); }
|
||||
.status-dot.stale { background: var(--neon-amber); }
|
||||
.status-dot.idle { background: var(--neon-red); }
|
||||
.mix-track {
|
||||
height: 8px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.mix-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--neon-magenta), var(--neon-cyan));
|
||||
}
|
||||
.avg {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.chart-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.graph-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.range-tabs {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.range-tabs button {
|
||||
min-width: 0;
|
||||
}
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.line-chart {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
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);
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
async function loginRemoteApp(): Promise<void> {
|
||||
waitingRemote.value = true;
|
||||
try {
|
||||
await auth.loginRemoteApp();
|
||||
void router.push({ name: "dashboard" });
|
||||
} catch {
|
||||
/* surfaced via auth.error */
|
||||
} finally {
|
||||
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>
|
||||
<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 ? "..." : "sign in with Nostr" }}
|
||||
<small>Archipelago identity or browser extension</small>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="primary big"
|
||||
:disabled="auth.busy"
|
||||
@click="loginRemoteApp"
|
||||
>
|
||||
{{ waitingRemote ? "waiting for signer..." : "open signer app" }}
|
||||
<small>Primal, Amber, or any Nostr Connect signer</small>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="big"
|
||||
:disabled="auth.busy"
|
||||
@click="showBunkerInput = !showBunkerInput"
|
||||
>
|
||||
bunker URI
|
||||
<small>advanced fallback · 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">
|
||||
choose your signer, select your identity, and approve the sign-in request.
|
||||
</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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<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(async () => {
|
||||
if (auth.hasPendingRemoteAppLogin()) {
|
||||
try {
|
||||
await auth.resumeRemoteAppLogin();
|
||||
} catch {
|
||||
/* surfaced on the login screen if resume fails */
|
||||
}
|
||||
void router.replace({ name: auth.isLoggedIn ? "dashboard" : "login", query: auth.isLoggedIn ? {} : { remote: "return" } });
|
||||
return;
|
||||
}
|
||||
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">Finishing remote signer login...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.callback {
|
||||
width: min(460px, 92vw);
|
||||
text-align: center;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user