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 = ` + +
+ + ${panelHTML} +
+ `; + + // ── 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();