From 9b81ba70b68162e6bfe603950a3b8a3bc97d4bc3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 20:51:21 -0700 Subject: [PATCH] feat(shell): add share button to global header with QR, copy link, email invite Extract canvas inline share panel into reusable web component and add it to the shell header between notification bell and settings gear. Canvas now uses the component too, removing ~230 lines of inline HTML/CSS/JS. Co-Authored-By: Claude Opus 4.6 --- server/shell.ts | 1 + shared/components/rstack-share-panel.ts | 362 ++++++++++++++++++++++++ website/canvas.html | 265 +---------------- website/shell.ts | 2 + 4 files changed, 372 insertions(+), 258 deletions(-) create mode 100644 shared/components/rstack-share-panel.ts diff --git a/server/shell.ts b/server/shell.ts index 87a56c3..617b717 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -186,6 +186,7 @@ export function renderShell(opts: ShellOptions): string { Try Demo + diff --git a/shared/components/rstack-share-panel.ts b/shared/components/rstack-share-panel.ts new file mode 100644 index 0000000..1c539bd --- /dev/null +++ b/shared/components/rstack-share-panel.ts @@ -0,0 +1,362 @@ +/** + * — Share button with dropdown panel. + * + * Shows a share icon in the header. Click opens a dropdown with: + * - QR code for the current page URL + * - Copyable link input + * - Email invite form (POST /api/spaces/:slug/invite) + * + * Attributes: + * share-url — Override the URL to share (defaults to window.location.href) + */ + +export class RStackSharePanel extends HTMLElement { + #shadow: ShadowRoot; + #open = false; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.#render(); + } + + get #shareUrl(): string { + return this.getAttribute("share-url") || window.location.href; + } + + get #spaceSlug(): string { + return document.body?.dataset?.spaceSlug || ""; + } + + #togglePanel() { + this.#open = !this.#open; + this.#render(); + } + + async #copyUrl() { + try { + await navigator.clipboard.writeText(this.#shareUrl); + const btn = this.#shadow.getElementById("copy-btn"); + if (btn) { + btn.textContent = "Copied!"; + setTimeout(() => { btn.textContent = "Copy"; }, 2000); + } + } catch { /* clipboard unavailable */ } + } + + async #sendInvite() { + const input = this.#shadow.getElementById("email-input") as HTMLInputElement; + const status = this.#shadow.getElementById("email-status"); + if (!input || !status) return; + + const email = input.value.trim(); + if (!email) return; + + const slug = this.#spaceSlug; + if (!slug) { + status.textContent = "No space context"; + status.style.color = "#ef4444"; + setTimeout(() => { status.textContent = ""; }, 3000); + return; + } + + status.textContent = "Sending..."; + status.style.color = ""; + try { + const res = await fetch(`/api/spaces/${slug}/invite`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, shareUrl: this.#shareUrl }), + }); + if (res.ok) { + status.textContent = "Invite sent!"; + status.style.color = "#10b981"; + input.value = ""; + } else { + status.textContent = "Failed to send"; + status.style.color = "#ef4444"; + } + } catch { + status.textContent = "Failed to send"; + status.style.color = "#ef4444"; + } + setTimeout(() => { status.textContent = ""; }, 4000); + } + + #render() { + const url = this.#shareUrl; + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}`; + + let panelHTML = ""; + if (this.#open) { + panelHTML = ` +
+
+ Share + +
+
+
+ QR Code +
+ +
+ + + +
+
+
+ `; + } + + this.#shadow.innerHTML = ` + + + `; + + // ── Event listeners ── + this.#shadow.getElementById("share-toggle")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#togglePanel(); + }); + + this.#shadow.getElementById("close-btn")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#open = false; + this.#render(); + }); + + this.#shadow.getElementById("copy-btn")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#copyUrl(); + }); + + this.#shadow.getElementById("send-btn")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#sendInvite(); + }); + + // Enter key on email input triggers send + this.#shadow.getElementById("email-input")?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { + e.stopPropagation(); + this.#sendInvite(); + } + }); + + // Stop propagation from panel clicks + this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation()); + + // Close on outside click + if (this.#open) { + document.addEventListener("click", () => { + if (this.#open) { + this.#open = false; + this.#render(); + } + }, { once: true }); + } + } + + static define(tag = "rstack-share-panel") { + if (!customElements.get(tag)) customElements.define(tag, RStackSharePanel); + } +} + +// ============================================================================ +// STYLES +// ============================================================================ + +const STYLES = ` +:host { + display: inline-flex; + align-items: center; +} + +.share-wrapper { + position: relative; +} + +.share-btn { + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + cursor: pointer; + padding: 6px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} +.share-btn:hover { + color: var(--rs-text-primary, #e2e8f0); + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); +} + +.panel { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + width: 320px; + border-radius: 10px; + background: var(--rs-bg-surface, #1e293b); + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + box-shadow: var(--rs-shadow-lg, 0 8px 30px rgba(0,0,0,0.3)); + z-index: 200; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1)); +} + +.panel-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--rs-text-primary, #e2e8f0); +} + +.close-btn { + background: none; + border: none; + font-size: 18px; + color: var(--rs-text-muted, #94a3b8); + cursor: pointer; + padding: 0 4px; + line-height: 1; +} +.close-btn:hover { + color: var(--rs-text-primary, #e2e8f0); +} + +.panel-body { + padding: 0; +} + +.section { + padding: 12px 16px; + border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1)); +} +.section:last-child { + border-bottom: none; +} + +.qr-section { + text-align: center; +} +.qr-section img { + border-radius: 8px; +} + +.link-row { + display: flex; + gap: 8px; + align-items: center; +} + +.link-row input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + border-radius: 6px; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); + outline: none; + min-width: 0; +} +.link-row input:focus { + border-color: #14b8a6; +} + +label { + display: block; + font-size: 12px; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 8px; +} + +.email-row { + display: flex; + gap: 8px; + align-items: center; +} + +.email-row input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + border-radius: 6px; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); + outline: none; + min-width: 0; +} +.email-row input:focus { + border-color: #14b8a6; +} + +.email-status { + font-size: 11px; + margin-top: 6px; + min-height: 16px; +} + +.btn-teal { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: #14b8a6; + color: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} +.btn-teal:hover { + background: #0d9488; +} + +.btn-blue { + padding: 6px 14px; + border: none; + border-radius: 6px; + background: #3b82f6; + color: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} +.btn-blue:hover { + background: #2563eb; +} +`; diff --git a/website/canvas.html b/website/canvas.html index d0e5e69..a10904c 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -940,161 +940,6 @@ touch-action: none; /* Prevent browser gestures, handle manually */ } - /* ── Share badge & panel ── */ - /* share-badge is now a header icon button — see .rstack-header__share-btn in shell.css overrides below */ - - #share-panel { - position: fixed; - top: 56px; - right: 60px; - width: 320px; - background: var(--rs-bg-surface); - border-radius: 12px; - box-shadow: var(--rs-shadow-lg); - z-index: 10001; - display: none; - overflow: hidden; - } - - #share-panel.open { - display: block; - } - - #share-panel-header { - padding: 12px 16px; - border-bottom: 1px solid var(--rs-toolbar-panel-border); - display: flex; - align-items: center; - justify-content: space-between; - } - - #share-panel-header h3 { - font-size: 14px; - color: var(--rs-text-primary); - margin: 0; - } - - #share-panel-close { - background: none; - border: none; - font-size: 18px; - color: var(--rs-text-secondary); - cursor: pointer; - padding: 0 4px; - line-height: 1; - } - - #share-panel-close:hover { - color: var(--rs-text-primary); - } - - #share-panel-body { - padding: 0; - } - - .share-section { - padding: 12px 16px; - border-bottom: 1px solid var(--rs-border-subtle); - } - - .share-section:last-child { - border-bottom: none; - } - - #share-qr { - display: block; - margin: 0 auto; - border-radius: 8px; - } - - .share-link-row { - display: flex; - gap: 8px; - align-items: center; - } - - .share-link-row input { - flex: 1; - padding: 6px 10px; - border: 1px solid var(--rs-input-border); - border-radius: 6px; - font-size: 12px; - color: var(--rs-input-text); - background: var(--rs-input-bg); - outline: none; - } - - .share-link-row input:focus { - border-color: #14b8a6; - } - - #share-copy-btn { - padding: 6px 14px; - border: none; - border-radius: 6px; - background: #14b8a6; - color: white; - font-size: 12px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - transition: background 0.15s; - } - - #share-copy-btn:hover { - background: #0d9488; - } - - .share-section label { - display: block; - font-size: 12px; - color: var(--rs-text-muted); - margin-bottom: 8px; - } - - .share-email-row { - display: flex; - gap: 8px; - align-items: center; - } - - .share-email-row input { - flex: 1; - padding: 6px 10px; - border: 1px solid var(--rs-input-border); - border-radius: 6px; - font-size: 12px; - color: var(--rs-input-text); - background: var(--rs-bg-surface); - outline: none; - } - - .share-email-row input:focus { - border-color: #14b8a6; - } - - #share-send-btn { - padding: 6px 14px; - border: none; - border-radius: 6px; - background: #3b82f6; - color: white; - font-size: 12px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - transition: background 0.15s; - } - - #share-send-btn:hover { - background: #2563eb; - } - - #share-email-status { - font-size: 11px; - margin-top: 6px; - min-height: 16px; - } /* ── People Online badge ── */ #people-online-badge { @@ -1377,12 +1222,8 @@ /* Dark/light mode handled by CSS custom properties in theme.css */ - /* ── Share & People panel mobile ── */ + /* ── People panel mobile ── */ @media (max-width: 640px) { - #share-panel { - width: calc(100vw - 32px); - right: 16px; - } #people-online-badge { right: 16px; bottom: 12px; @@ -2110,7 +1951,8 @@
Try Demo - + +
@@ -2416,29 +2258,6 @@ -
-
-

Share Space

- -
-
- - - -
-
@@ -2659,6 +2478,8 @@ onArrowRemoved, } from "@lib"; import { RStackIdentity } from "@shared/components/rstack-identity"; + import { RStackNotificationBell } from "@shared/components/rstack-notification-bell"; + import { RStackSharePanel } from "@shared/components/rstack-share-panel"; import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher"; import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher"; import { RStackTabBar } from "@shared/components/rstack-tab-bar"; @@ -2672,6 +2493,8 @@ // Register shell header components RStackIdentity.define(); + RStackNotificationBell.define(); + RStackSharePanel.define(); RStackAppSwitcher.define(); RStackSpaceSwitcher.define(); RStackTabBar.define(); @@ -3538,80 +3361,6 @@ } }); - // ── Share panel ── - const sharePanel = document.getElementById("share-panel"); - const shareBadge = document.getElementById("share-badge"); - - function getShareUrl() { - const proto = window.location.protocol; - const host = window.location.host.split(":")[0]; - if (host.endsWith("rspace.online") && host.split(".").length >= 3) { - return `${proto}//${host}/rspace`; - } - return `${proto}//${window.location.host}/${communitySlug}/rspace`; - } - - if (shareBadge) { - shareBadge.addEventListener("click", () => { - const isOpen = sharePanel.classList.toggle("open"); - if (isOpen) { - // Close people panel if open - peoplePanel.classList.remove("open"); - const url = getShareUrl(); - document.getElementById("share-url").value = url; - document.getElementById("share-qr").src = - `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}`; - } - }); - } - - document.getElementById("share-panel-close")?.addEventListener("click", () => { - sharePanel.classList.remove("open"); - }); - - document.getElementById("share-copy-btn")?.addEventListener("click", async () => { - const url = document.getElementById("share-url").value; - await navigator.clipboard.writeText(url); - const btn = document.getElementById("share-copy-btn"); - btn.textContent = "Copied!"; - setTimeout(() => btn.textContent = "Copy", 2000); - }); - - document.getElementById("share-send-btn")?.addEventListener("click", async () => { - const email = document.getElementById("share-email").value.trim(); - const status = document.getElementById("share-email-status"); - if (!email) return; - status.textContent = "Sending..."; - status.style.color = ""; - try { - const res = await fetch(`/api/spaces/${communitySlug}/invite`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, shareUrl: getShareUrl() }), - }); - if (res.ok) { - status.textContent = "Invite sent!"; - status.style.color = "#10b981"; - document.getElementById("share-email").value = ""; - } else { - status.textContent = "Failed to send"; - status.style.color = "#ef4444"; - } - } catch { - status.textContent = "Failed to send"; - status.style.color = "#ef4444"; - } - setTimeout(() => status.textContent = "", 4000); - }); - - // Click-outside closes share panel - document.addEventListener("click", (e) => { - if (sharePanel?.classList.contains("open") && - !sharePanel.contains(e.target) && - (!shareBadge || !shareBadge.contains(e.target))) { - sharePanel.classList.remove("open"); - } - }); function navigateToPeer(cursor) { const rect = canvas.getBoundingClientRect(); diff --git a/website/shell.ts b/website/shell.ts index 3fe60e8..25da0dd 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -17,6 +17,7 @@ import { RStackSpaceSettings } from "../shared/components/rstack-space-settings" import { RStackModuleSetup } from "../shared/components/rstack-module-setup"; import { RStackHistoryPanel } from "../shared/components/rstack-history-panel"; import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; +import { RStackSharePanel } from "../shared/components/rstack-share-panel"; import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay"; import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; import { rspaceNavUrl } from "../shared/url-helpers"; @@ -40,6 +41,7 @@ RStackSpaceSettings.define(); RStackModuleSetup.define(); RStackHistoryPanel.define(); RStackOfflineIndicator.define(); +RStackSharePanel.define(); RStackCollabOverlay.define(); RStackUserDashboard.define();