377 lines
8.9 KiB
TypeScript
377 lines
8.9 KiB
TypeScript
/**
|
|
* <rstack-share-panel> — 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 || "";
|
|
}
|
|
|
|
/** Public toggle — called from identity dropdown on mobile */
|
|
toggle() { this.#togglePanel(); }
|
|
|
|
#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 = `
|
|
<div class="panel">
|
|
<div class="panel-header">
|
|
<span class="panel-title">Share</span>
|
|
<button class="close-btn" id="close-btn">×</button>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div class="section qr-section">
|
|
<img src="${qrSrc}" width="180" height="180" alt="QR Code">
|
|
</div>
|
|
<div class="section link-row">
|
|
<input id="url-input" type="text" readonly value="${url.replace(/"/g, """)}">
|
|
<button id="copy-btn" class="btn-teal">Copy</button>
|
|
</div>
|
|
<div class="section">
|
|
<label>Invite by email</label>
|
|
<div class="email-row">
|
|
<input id="email-input" type="email" placeholder="friend@example.com">
|
|
<button id="send-btn" class="btn-blue">Send</button>
|
|
</div>
|
|
<div id="email-status" class="email-status"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
this.#shadow.innerHTML = `
|
|
<style>${STYLES}</style>
|
|
<div class="share-wrapper">
|
|
<button class="share-btn" id="share-toggle" aria-label="Share this page">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
|
|
<polyline points="16 6 12 2 8 6"/>
|
|
<line x1="12" y1="2" x2="12" y2="15"/>
|
|
</svg>
|
|
</button>
|
|
${panelHTML}
|
|
</div>
|
|
`;
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.share-btn { display: none; }
|
|
.panel {
|
|
position: fixed;
|
|
top: 60px;
|
|
right: 8px;
|
|
left: 8px;
|
|
width: auto;
|
|
}
|
|
}
|
|
`;
|