sirius/js/profile-menu.js
Local Dev 6ce9de33a0 feat(sirius-x/portal): New wallet card, siriusProfile state, ?mode= deep-links
Closes the loop between the nav's profile dropdown and portal.html:

- Adds a 🆕 New wallet card that calls BuiltInWallet.create() to generate
  a fresh BIP-39 phrase in the browser. The phrase is shown once for the
  user to write down; sign-in only unlocks after they tick the 'I have
  written this down' acknowledgement. Copy button included.
- Sign-in (both New and Import paths) now writes localStorage.siriusProfile
  = { address, tokenAddress, signedInAt } so any page on the same origin
  can render 'signed in' state. Sign-out clears the key and also resets
  the New wallet card so a re-sign-in starts clean.
- Handles ?mode=new|import|wc from the profile dropdown's deep-links:
  scrolls the matching card into view and briefly outlines it in acid so
  the user knows which one they were sent to.

profile-menu.js:
- Re-renders the dropdown on every open() call rather than caching the
  first paint, so a sign-in that happens *after* the script's initial
  run (same tab: portal.html; other tabs: storage event) reflects in the
  nav without needing a full reload.
- Listens for the storage event (cross-tab) and a custom
  'siriusProfileChanged' event (same-tab) — portal.html fires that on
  every writeProfile/clearProfile call.
2026-09-07 00:15:43 +02:00

153 lines
7 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";
const PORTAL_URL = "/sirius-x/portal.html";
// 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
// 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="/sirius-x/admin/" 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]));
}
})();