sirius/js/profile-menu.js
Local Dev 1cca60c4df fix(sirius-x): profile-menu URLs, landing search, empty-wallet hint, drop Pantheon
Five UX fixes from a review pass:

1. profile-menu.js: dropdown links (New/Add/WC wallet, Admin) were
   absolute /sirius-x/portal.html paths. That's fine on silentmode.st
   but under the BCNR route (sirius.x/) the origin is different, so
   the gateway forwarded to Sia which returned 'NoSuchKey' XML. Now
   derive the base URL from the nav's own .portal anchor href — which
   is already set per page with the right relative path — so it works
   from the root, from subdirs, and under any BCNR gateway. Admin URL
   is derived from the same base.

2. Landing hero got a search input. Submitting it normalises the
   label ([a-z0-9-], 63 chars) and redirects to register.html?q=<label>.
   register.html now honours ?q= on load: prefills the input, triggers
   the parallel multi-TLD lookup, and scrolls into view. Single source
   of truth stays in register.html; the landing just hands it a query.

3. Portal TLD-mint error path now special-cases 'wallet is empty' and
   shows a friendlier message with the wallet's address and links to
   two chipnet faucets, so the fix is one click away instead of a
   guess.

4. Removed 'Pantheon' from every page's top nav — it's a section on
   the landing that anyone scrolling will discover, and keeping it in
   the nav crowded the bar.

5. Cache-buster ?v=20260907rel on the profile-menu.js script include
   across all 7 pages so browsers that cached the pre-fix version
   pick up the new one on next load.
2026-09-07 20:32:14 +02:00

167 lines
7.8 KiB
JavaScript

// Transforms the "🔑 Sign in" nav pill on every sirius.x page into a dropdown
// exposing the three wallet-onboarding options directly (New / Import /
// WizardConnect). Signed-in state is read from localStorage.'siriusProfile'
// (portal.html writes/clears it on sign-in / sign-out) — when present, the
// menu switches to "profile" mode: address + sign-out link.
//
// One include line per page: <script defer src="/sirius-x/js/profile-menu.js"></script>
// (path is absolute to silentmode.st root; the BCNR gateway serves the same URL).
(() => {
const NS = "silentmode.st";
// Find the .portal anchor already in the nav (the "Sign in" pill).
const anchor = document.querySelector(".topnav a.portal");
if (!anchor) return; // page has no nav-portal button; nothing to do
// Reuse the anchor's own href as the base for the dropdown links. The nav
// sets it to the right relative path per page (./portal.html at root,
// ../portal.html in subdirs). An absolute /sirius-x/portal.html would
// break under the BCNR route (sirius.x/) — the origin is different, so
// the gateway hits Sia with a key that does not exist and returns
// NoSuchKey. Deriving from the anchor keeps every context correct.
const PORTAL_URL = anchor.getAttribute("href") || "./portal.html";
// Admin lives one directory up-or-over relative to the same root — same
// trick: replace the portal.html basename with admin/ on the resolved
// portal path. If the anchor is a hash or empty for some reason, fall
// back to a same-directory link.
const ADMIN_URL = PORTAL_URL.includes("portal.html")
? PORTAL_URL.replace(/portal\.html.*$/, "admin/")
: "./admin/";
// Read profile state — portal.html sets this after successful sign-in with
// { address, tokenAddress, signedInAt }; clears on sign-out. The read has to
// run every time the button label refreshes or the menu opens, because
// sign-in can happen on this same page (portal.html) or in another tab
// *after* this script's initial run.
const readProfile = () => {
try { return JSON.parse(localStorage.getItem("siriusProfile") || "null"); } catch { return null; }
};
// Build wrapper + button + (empty) menu around the existing pill.
const wrap = document.createElement("div");
wrap.className = "profile-wrap";
const btn = document.createElement("button");
btn.type = "button";
btn.className = anchor.className; // reuse .portal (and .here if present)
btn.setAttribute("aria-haspopup", "menu");
btn.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.className = "profile-menu";
menu.setAttribute("role", "menu");
menu.hidden = true;
// Re-renders the button label AND the menu innerHTML from the current
// profile in localStorage. Called at load, on every open(), and whenever
// another tab updates the storage key.
function render() {
const profile = readProfile();
btn.textContent = profile ? "🔑 " + shortAddr(profile.address) + " ▾" : "🔑 Wallet ▾";
if (profile) {
menu.innerHTML = `
<div class="profile-header">
<span class="profile-hint">Signed in</span>
<code>${escapeAttr(profile.address)}</code>
</div>
<a href="${PORTAL_URL}" role="menuitem">📋 My names</a>
<a href="${ADMIN_URL}" role="menuitem">🛠 Admin</a>
<hr>
<a href="#" role="menuitem" data-action="signout">🚪 Sign out</a>
`;
} else {
menu.innerHTML = `
<div class="profile-header"><span class="profile-hint">Wallet onboarding</span></div>
<a href="${PORTAL_URL}?mode=new" role="menuitem">🆕 New wallet <span class="mi-hint">generate a fresh seed</span></a>
<a href="${PORTAL_URL}?mode=import" role="menuitem">📥 Add wallet <span class="mi-hint">import a seed phrase</span></a>
<a href="${PORTAL_URL}?mode=wc" role="menuitem">🔗 WizardConnect <span class="mi-hint">Cashonize / Paytaca</span></a>
<hr>
<a href="${PORTAL_URL}" role="menuitem">🔑 Open sign-in page →</a>
`;
}
}
render();
// Toggle + close-on-outside. Re-render on open so a sign-in that happened
// after page load (same tab: portal.html; other tab: storage event) shows
// up without a full reload.
const open = () => { render(); menu.hidden = false; btn.setAttribute("aria-expanded", "true"); };
const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
// Storage events fire only in *other* tabs, but they cover the cross-tab case.
// Same-tab: portal.html dispatches "siriusProfileChanged" on window after
// it writes/clears the key; render on that too.
window.addEventListener("storage", (e) => { if (e.key === "siriusProfile") render(); });
window.addEventListener("siriusProfileChanged", render);
btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); });
document.addEventListener("click", (e) => { if (!wrap.contains(e.target)) close(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
// Sign-out clears localStorage and reloads (portal.html re-reads state).
menu.addEventListener("click", (e) => {
const t = e.target.closest("[data-action]");
if (t?.dataset.action === "signout") {
e.preventDefault();
try { localStorage.removeItem("siriusProfile"); } catch {}
// Also reload the current page so any signed-in UI resets.
location.reload();
}
});
wrap.appendChild(btn);
wrap.appendChild(menu);
anchor.replaceWith(wrap);
// Inject the dropdown's CSS once. Kept inline so a single script tag is
// the whole install; consumers don't need a matching stylesheet.
if (!document.getElementById("profile-menu-css")) {
const s = document.createElement("style");
s.id = "profile-menu-css";
s.textContent = `
.topnav .profile-wrap { position: relative; margin-left: auto; }
.topnav .profile-wrap button.portal { cursor: pointer; font-family: inherit; }
.topnav .profile-menu {
position: absolute; right: 0; top: calc(100% + 6px);
min-width: 240px; z-index: 30;
background: rgba(20, 26, 36, .98); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,.12); border-radius: 12px;
padding: 8px; display: flex; flex-direction: column; gap: 2px;
box-shadow: 0 8px 32px rgba(0,0,0,.4);
}
.topnav .profile-menu[hidden] { display: none; }
.topnav .profile-menu > a {
display: flex; flex-direction: column; gap: 2px;
padding: 8px 10px; border-radius: 8px;
color: #e7eaf1; text-decoration: none; font-size: 14px;
}
.topnav .profile-menu > a:hover { background: rgba(214,255,61,.10); color: #d6ff3d; }
.topnav .profile-menu .mi-hint {
color: #5e6678; font-size: 11.5px; font-weight: 400; letter-spacing: 0;
}
.topnav .profile-menu hr {
border: 0; border-top: 1px solid rgba(255,255,255,.08); margin: 4px 2px;
}
.topnav .profile-menu .profile-header {
padding: 6px 10px 10px; display: flex; flex-direction: column; gap: 4px;
border-bottom: 1px solid rgba(255,255,255,.08); margin-bottom: 4px;
}
.topnav .profile-menu .profile-hint {
color: #8b98a9; font-size: 11px; text-transform: uppercase; letter-spacing: .4px;
}
.topnav .profile-menu code {
font-family: ui-monospace, monospace; font-size: 11.5px;
color: #e7eaf1; word-break: break-all; background: transparent;
}
`;
document.head.appendChild(s);
}
// ---------- helpers ----------
function shortAddr(a) {
if (!a) return "wallet";
const i = a.indexOf(":");
const body = i > 0 ? a.slice(i + 1) : a;
return body.slice(0, 6) + "…" + body.slice(-4);
}
function escapeAttr(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
}
})();