c5a326f3cc42beca0b59c284a2819763548c166a max Wed Jul 8 05:31:13 2026 -0700 Add top-right "Share a link" and "Login" links to the menu bar, refs #10138 New js/topLinks.js drives two links at the top-right of the menu bar on all pages: - Login: links to the login page when logged out; when logged in it shows the username and opens an account dialog (My Sessions / My Custom Tracks / My Track Hubs, change password, sign out). - Share a link: on hgTracks, one click saves the current view as a session and shows a copyable short link, with an optional "Specify name" rename step; works logged-in or anonymously (reserved user "l"). On hgTrackUi and the hgc/hgGene item-details popup it instead shares the page URL with the hgsid stripped and the db argument kept. hgSession gains two JSON endpoints (hgS_doSaveSessionJson, hgS_doRenameSessionJson) that reuse saveCartAsSession() and addSessionLink(). The menu bar is patched in lib/web.c (CGI pages) and hgMenubar.c (static pages) via comment placeholders in globalNavBar.inc. On narrow/phone screens the links collapse into a menu icon with a dropdown so they no longer overlap the menu. diff --git src/hg/js/topLinks.js src/hg/js/topLinks.js new file mode 100644 index 00000000000..62cca2e458f --- /dev/null +++ src/hg/js/topLinks.js @@ -0,0 +1,383 @@ +// topLinks.js - behavior for the two top-right menu-bar links, "Login" and "Share a link". +// +// Login: when the user is logged in, the menu item shows their username and opens a small +// account dialog (change password, sign out). When logged out it is a plain link to hgSession, +// so this script does nothing for it. +// +// Share a link (browser pages only): saves the current view as a session and shows a copyable +// short link. Logged-in users get to name the session and choose whether others may open it; +// logged-out users get an anonymous link created on the fly. +// +// The C code that builds the menu bar (lib/web.c menuBar() for CGI pages, hgMenubar.c for static +// pages) fills in the data-* attributes that this script reads. The dialog is a self-contained +// overlay so it works on static pages too (no jQuery UI dependency). + +/* jshint esversion: 8 */ +/* global $, document, window, URL, getHgsid, copyToClipboard */ + +var topLinks = (function() { + "use strict"; + + // Small helper to build an element with properties and inline styles set from the DOM + // (assigning to element.style.* is CSP-safe, unlike a style="" attribute in markup). + function el(tag, props, styles) { + var e = document.createElement(tag); + var k; + if (props) + for (k in props) + if (props.hasOwnProperty(k)) e[k] = props[k]; + if (styles) + for (k in styles) + if (styles.hasOwnProperty(k)) e.style[k] = styles[k]; + return e; + } + + var closeCurrent = null; // closer for the currently open modal, if any + + function closeModal() { + if (closeCurrent) { closeCurrent(); closeCurrent = null; } + } + + // Show a centered modal with the given title and body node. Returns the body element so the + // caller can replace its contents later (e.g. swap a spinner for the resulting link). + function showModal(titleText, bodyNode, widthPx) { + closeModal(); + var overlay = el("div", {}, { + position: "fixed", top: "0", left: "0", right: "0", bottom: "0", + background: "rgba(0,0,0,0.4)", zIndex: "10000" + }); + var box = el("div", {}, { + position: "fixed", top: "90px", left: "50%", transform: "translateX(-50%)", + background: "#fff", color: "#000", border: "1px solid #888", borderRadius: "4px", + boxShadow: "0 2px 12px rgba(0,0,0,0.4)", width: (widthPx || 430) + "px", maxWidth: "92%", + zIndex: "10001", fontSize: "14px" + }); + var titleBar = el("div", {}, { + background: "#00457c", color: "#fff", padding: "8px 12px", fontWeight: "bold", + borderTopLeftRadius: "4px", borderTopRightRadius: "4px", position: "relative" + }); + titleBar.appendChild(document.createTextNode(titleText)); + var x = el("span", {title: "Close", textContent: "×"}, { + position: "absolute", right: "10px", top: "5px", cursor: "pointer", + fontSize: "18px", lineHeight: "18px" + }); + x.addEventListener("click", closeModal); + titleBar.appendChild(x); + var body = el("div", {}, {padding: "14px"}); + if (bodyNode) body.appendChild(bodyNode); + box.appendChild(titleBar); + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Swallow Escape and clicks so they don't also reach an underlying dialog's own + // close-on-escape / click-outside handlers (e.g. the hgc item-details popup). + function onKey(ev) { + if (ev.key === "Escape") { + ev.preventDefault(); + ev.stopImmediatePropagation(); + closeModal(); + } + } + overlay.addEventListener("click", function(ev) { + if (ev.target === overlay) + closeModal(); + ev.stopPropagation(); + }); + document.addEventListener("keydown", onKey, true); // capture: run before other handlers + closeCurrent = function() { + document.removeEventListener("keydown", onKey, true); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + }; + return body; + } + + // ---- Login / account dialog -------------------------------------------------------------- + + function showLoginDialog(link) { + var user = link.getAttribute("data-username"); + var logoutUrl = link.getAttribute("data-logouturl"); + var changePwUrl = link.getAttribute("data-changepwurl"); + var body = document.createElement("div"); + var p = el("p"); + p.appendChild(document.createTextNode("Signed in as ")); + p.appendChild(el("b", {textContent: user})); // textContent avoids HTML injection + p.appendChild(document.createTextNode(".")); + body.appendChild(p); + + // helper: add an <li><a> to a list + function addLink(ul, href, text) { + var li = el("li", {}, {margin: "6px 0"}); + li.appendChild(el("a", {href: href, textContent: text})); + ul.appendChild(li); + } + + // "My Data" navigation: sessions, custom tracks, uploaded track hubs (hubSpace). + var hgsid = getHgsidSafe(); + var navUl = el("ul", {}, {listStyle: "none", margin: "0 0 10px 0", padding: "0"}); + addLink(navUl, "../cgi-bin/hgSession?hgS_doMainPage=1&hgsid=" + hgsid, "My Sessions"); + addLink(navUl, "../cgi-bin/hgCustom?hgsid=" + hgsid, "My Custom Tracks"); + addLink(navUl, "../cgi-bin/hgHubConnect?hgsid=" + hgsid + "#hubUpload", "My Track Hubs"); + body.appendChild(navUl); + + // Account actions. + var ul = el("ul", {}, {listStyle: "none", margin: "0", padding: "0", + borderTop: "1px solid #ddd", paddingTop: "8px"}); + if (changePwUrl) + addLink(ul, changePwUrl, "Change password"); + addLink(ul, logoutUrl, "Sign out"); + body.appendChild(ul); + showModal("Account", body); + } + + // ---- Share a link ------------------------------------------------------------------------ + + function getHgsidSafe() { + if (typeof getHgsid === "function") + return getHgsid(); + var m = /[?&]hgsid=([^&]+)/.exec(window.location.search); + return m ? m[1] : ""; + } + + // Clipboard SVG icon (same FontAwesome glyph used by printCopyToClipboardButton in hgSession.c). + var clipboardSvg = "<svg style='width:0.9em;vertical-align:middle;margin-right:4px' " + + "xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><path d='M502.6 70.63l-61.25-61.25" + + "C435.4 3.371 427.2 0 418.7 0H255.1c-35.35 0-64 28.66-64 64l.0195 256C192 355.4 220.7 384 256 " + + "384h192c35.2 0 64-28.8 64-64V93.25C512 84.77 508.6 76.63 502.6 70.63zM464 320c0 8.836-7.164 " + + "16-16 16H255.1c-8.838 0-16-7.164-16-16L239.1 64.13c0-8.836 7.164-16 16-16h128L384 96c0 17.67 " + + "14.33 32 32 32h47.1V320zM272 448c0 8.836-7.164 16-16 16H63.1c-8.838 0-16-7.164-16-16L47.98 " + + "192.1c0-8.836 7.164-16 16-16H160V128H63.99c-35.35 0-64 28.65-64 64l.0098 256C.002 483.3 28.66 " + + "512 64 512h192c35.2 0 64-28.8 64-64v-32h-47.1L272 448z'/></svg>"; + + // POST to the hgSession JSON endpoint. On {name,url} success call onResult(data); on {error} + // or transport failure show the message in statusEl. + function postJson(params, statusEl, onResult) { + statusEl.style.color = "#000"; + statusEl.textContent = "Working…"; + $.ajax({ + type: "POST", + url: "../cgi-bin/hgSession", + data: params, + dataType: "json", + success: function(data) { + if (data && data.url) + onResult(data); + else { + statusEl.style.color = "#a00"; + statusEl.textContent = (data && data.error) ? data.error : "Could not create link."; + } + }, + error: function() { + statusEl.style.color = "#a00"; + statusEl.textContent = "Could not reach the server. Please try again."; + } + }); + } + + // Append the "how to manage this link" note (session mode only). Logged in: point to the + // My Sessions page. Logged out: invite the user to log in (via the top-right Login link). + function appendManageNote(body, loggedIn) { + var note = el("p", {}, {marginTop: "14px"}); + if (loggedIn) { + note.appendChild(document.createTextNode("All of your saved links appear on your ")); + note.appendChild(el("a", {href: "../cgi-bin/hgSession?hgS_doMainPage=1&hgsid=" + + getHgsidSafe(), + target: "_blank", textContent: "My Sessions"})); + note.appendChild(document.createTextNode( + " page, where you can rename, update, or remove them at any time.")); + } else { + note.appendChild(document.createTextNode("You are not logged in. ")); + var loginEl = document.getElementById("loginLink"); + note.appendChild(el("a", {href: loginEl ? loginEl.getAttribute("href") : "../cgi-bin/hgLogin", + textContent: "Log in"})); + note.appendChild(document.createTextNode( + " to give your links a name and to edit or update them later.")); + } + body.appendChild(note); + } + + // Show the resulting share URL with a "Copy to clipboard" button. opts (session mode): + // {name: <current session name>, session: true, loggedIn: <bool>}. A logged-in session link + // also gets a "Specify name" button. url mode passes no opts → just the link + Copy. + function showResult(body, url, opts) { + opts = opts || {}; + var canRename = opts.session && opts.loggedIn; + body.innerHTML = ""; + body.appendChild(el("p", {textContent: "You can share this link with collaborators, put " + + "it into figure legends or manuscripts. Links never time out:"}, {marginTop: "0"})); + // Read-only text region (not an <input>) so it's clear the URL isn't meant to be edited. + var urlBox = el("div", {id: "tlShareUrl", textContent: url}, + {background: "#f0f0f0", padding: "6px 8px", borderRadius: "4px", + wordBreak: "break-all", userSelect: "all", fontFamily: "monospace"}); + urlBox.setAttribute("data-copy", url); // copyToClipboard() reads data-copy or innerText + body.appendChild(urlBox); + + var btnRow = el("div", {}, {marginTop: "8px"}); + var copyBtn = el("button", {title: "Copy URL to clipboard"}); + copyBtn.setAttribute("data-target", "tlShareUrl"); + copyBtn.innerHTML = clipboardSvg + "Copy to clipboard"; + copyBtn.addEventListener("click", function(ev) { + if (typeof copyToClipboard === "function") copyToClipboard(ev); + }); + btnRow.appendChild(copyBtn); + + if (canRename) { + var nameBtn = el("button", {textContent: "Specify name"}, {marginLeft: "8px"}); + nameBtn.addEventListener("click", function() { showRename(body, url, opts); }); + btnRow.appendChild(nameBtn); + } + body.appendChild(btnRow); + + if (opts.session) + appendManageNote(body, opts.loggedIn); + else if (opts.pageNote) + body.appendChild(el("p", {textContent: "This link shows the page only. It does not " + + "restore the tracks you currently have displayed."}, {marginTop: "14px"})); + } + + // The "Specify name" editor: rename the session, then show the updated link. + function showRename(body, currentUrl, opts) { + body.innerHTML = ""; + body.appendChild(el("p", {textContent: "Name this link:"}, {marginTop: "0"})); + var nameInput = el("input", {type: "text", value: "", placeholder: "e.g. my favorite view"}, + {width: "100%", padding: "4px", margin: "6px 0", boxSizing: "border-box"}); + body.appendChild(nameInput); + var status = el("div", {}, {margin: "6px 0"}); + body.appendChild(status); + + var row = el("div", {}); + var saveBtn = el("button", {textContent: "Save"}); + saveBtn.addEventListener("click", function() { + var newName = nameInput.value.trim(); + if (!newName) { + status.style.color = "#a00"; + status.textContent = "Please enter a name."; + return; + } + postJson({hgsid: getHgsidSafe(), hgS_doRenameSessionJson: 1, + hgS_oldSessionName: opts.name, hgS_newSessionName: newName}, status, + function(data) { + showResult(body, data.url, {name: data.name, session: true, loggedIn: true}); + }); + }); + row.appendChild(saveBtn); + var cancelBtn = el("button", {textContent: "Cancel"}, {marginLeft: "8px"}); + cancelBtn.addEventListener("click", function() { showResult(body, currentUrl, opts); }); + row.appendChild(cancelBtn); + body.appendChild(row); + nameInput.focus(); + } + + // Return the current page URL with the hgsid query parameter removed (for hgTrackUi sharing). + function stripHgsid(url) { + try { + var u = new URL(url); + u.searchParams.delete("hgsid"); + return u.toString(); + } catch (e) { + return url.replace(/([?&])hgsid=[^&]*/, "$1").replace(/[?&]$/, ""); + } + } + + // Add name=val to url if it isn't already present (used to keep db= after stripping hgsid). + function ensureParam(url, name, val) { + try { + var u = new URL(url); + if (!u.searchParams.has(name)) + u.searchParams.set(name, val); + return u.toString(); + } catch (e) { + if (new RegExp("[?&]" + name + "=").test(url)) + return url; + return url + (url.indexOf("?") >= 0 ? "&" : "?") + name + "=" + encodeURIComponent(val); + } + } + + // Open a simple "here is the link" dialog for an arbitrary URL, with the hgsid stripped. + // Used by hgTrackUi (the current page) and by the hgc item-details popup in hgTracks.js. + // opts (optional): {ensureDb: <db> to add db= if missing, pageNote: true to note it's page-only}. + function shareUrlDialog(url, opts) { + opts = opts || {}; + var clean = stripHgsid(url); + if (opts.ensureDb) + clean = ensureParam(clean, "db", opts.ensureDb); + var body = document.createElement("div"); + showModal("Share a link", body, 720); + showResult(body, clean, {pageNote: opts.pageNote}); + } + + function showShareDialog(link) { + var mode = link.getAttribute("data-sharemode") || "session"; + + // hgTrackUi etc.: the shareable thing is just this page's URL without the hgsid. Note + // that it opens the page, not the user's session/tracks (like the hgc popup link). + if (mode === "url") { + shareUrlDialog(window.location.href, {pageNote: true}); + return; + } + + // Session mode (hgTracks): create a link right away so the user can just copy it. + var loggedIn = link.getAttribute("data-loggedin") === "1"; + var body = document.createElement("div"); + var status = el("p", {textContent: "Creating link…"}, {marginTop: "0"}); + body.appendChild(status); + showModal("Share a link", body, 720); + var params = {hgsid: getHgsidSafe(), hgS_doSaveSessionJson: 1}; + if (!loggedIn) + params.hgS_shareAnon = 1; // anonymous token link; no rename + postJson(params, status, function(data) { + showResult(body, data.url, {name: data.name, session: true, loggedIn: loggedIn}); + }); + } + + // ---- Wire up the menu items -------------------------------------------------------------- + + function init() { + var login = document.getElementById("loginLink"); + // Only intercept the click when logged in (data-username present); otherwise it is a + // plain link to hgSession and should navigate normally. + if (login && login.getAttribute("data-username")) { + login.addEventListener("click", function(ev) { + ev.preventDefault(); + showLoginDialog(login); + }); + } + var share = document.getElementById("shareLink"); + if (share) { + share.addEventListener("click", function(ev) { + ev.preventDefault(); + showShareDialog(share); + }); + } + // Narrow-screen hamburger: toggle the dropdown of top-right links (CSS shows it only on + // narrow viewports). Close on an outside click or after a link inside is chosen. + var toggle = document.getElementById("trToggle"); + var container = document.getElementById("topRightLinks"); + if (toggle && container) { + toggle.addEventListener("click", function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + var open = container.classList.toggle("trOpen"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + container.addEventListener("click", function(ev) { + if (ev.target.tagName === "A") { + container.classList.remove("trOpen"); + toggle.setAttribute("aria-expanded", "false"); + } + }); + document.addEventListener("click", function() { + container.classList.remove("trOpen"); + toggle.setAttribute("aria-expanded", "false"); + }); + } + } + + if (document.readyState === "loading") + document.addEventListener("DOMContentLoaded", init); + else + init(); + + return {showLoginDialog: showLoginDialog, showShareDialog: showShareDialog, + shareUrl: shareUrlDialog}; +})();