rspace-online/lib/folk-rapp.ts

609 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { rspaceNavUrl } from "../shared/url-helpers";
/**
* <folk-rapp> — Embeds a live rApp module as a shape on the canvas.
*
* Unlike folk-embed (generic URL iframe), folk-rapp understands the module
* system: it stores moduleId + spaceSlug, derives the iframe URL, shows
* the module's icon/badge in the header, and can switch modules in-place.
*
* PostMessage protocol:
* Parent → iframe: { source: "rspace-parent", type: "context", shapeId, space, moduleId }
* iframe → parent: { source: "rspace-canvas", type: "shape-updated", ... } (from CommunitySync)
* iframe → parent: { source: "rspace-rapp", type: "navigate", moduleId }
*/
// Module metadata for header display (subset of rstack-app-switcher badges)
const MODULE_META: Record<string, { badge: string; color: string; name: string; icon: string }> = {
rnotes: { badge: "rN", color: "#fcd34d", name: "rNotes", icon: "📝" },
rphotos: { badge: "rPh", color: "#f9a8d4", name: "rPhotos", icon: "📸" },
rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" },
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" },
rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" },
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" },
rfunds: { badge: "rF", color: "#bef264", name: "rFunds", icon: "🌊" },
rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" },
rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" },
rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" },
rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" },
rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" },
rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" },
rswag: { badge: "rSw", color: "#fda4af", name: "rSwag", icon: "🎨" },
rchoices: { badge: "rCo", color: "#f0abfc", name: "rChoices", icon: "🤔" },
rcal: { badge: "rC", color: "#7dd3fc", name: "rCal", icon: "📅" },
rtrips: { badge: "rT", color: "#6ee7b7", name: "rTrips", icon: "✈️" },
rmaps: { badge: "rM", color: "#86efac", name: "rMaps", icon: "🗺️" },
};
const styles = css`
:host {
background: #1e293b;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
min-width: 320px;
min-height: 240px;
overflow: hidden;
}
.rapp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--rapp-color, #334155);
color: #0f172a;
font-size: 12px;
font-weight: 700;
cursor: move;
user-select: none;
border-radius: 10px 10px 0 0;
}
.rapp-header-left {
display: flex;
align-items: center;
gap: 7px;
position: relative;
}
.rapp-badge {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.15);
font-size: 0.55rem;
font-weight: 900;
line-height: 1;
flex-shrink: 0;
}
.rapp-name {
font-size: 12px;
font-weight: 700;
}
.rapp-icon {
font-size: 13px;
}
.rapp-actions {
display: flex;
gap: 2px;
}
.rapp-actions button {
background: transparent;
border: none;
color: #0f172a;
cursor: pointer;
padding: 2px 5px;
border-radius: 4px;
font-size: 12px;
opacity: 0.7;
transition: opacity 0.15s, background 0.15s;
}
.rapp-actions button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
.rapp-content {
width: 100%;
height: calc(100% - 34px);
position: relative;
background: #0f172a;
}
.rapp-iframe {
width: 100%;
height: 100%;
border: none;
border-radius: 0 0 10px 10px;
}
.rapp-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #64748b;
font-size: 13px;
}
.rapp-loading .spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(100, 116, 139, 0.3);
border-top-color: #64748b;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.rapp-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 8px;
color: #ef4444;
font-size: 13px;
padding: 16px;
text-align: center;
}
/* Module picker (shown when no moduleId set) */
.rapp-picker {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
height: 100%;
overflow-y: auto;
}
.rapp-picker-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 4px;
}
.rapp-picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
color: #e2e8f0;
font-size: 13px;
border: none;
background: transparent;
text-align: left;
width: 100%;
}
.rapp-picker-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.rapp-picker-badge {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
font-size: 0.5rem;
font-weight: 900;
color: #0f172a;
flex-shrink: 0;
}
/* Module switcher dropdown */
.rapp-switcher {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 180px;
max-height: 300px;
overflow-y: auto;
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
padding: 4px;
z-index: 100;
display: none;
}
.rapp-switcher.open {
display: block;
}
.rapp-switcher-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 5px;
cursor: pointer;
color: #e2e8f0;
font-size: 12px;
border: none;
background: transparent;
width: 100%;
text-align: left;
transition: background 0.12s;
}
.rapp-switcher-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.rapp-switcher-item.active {
background: rgba(6, 182, 212, 0.15);
}
.rapp-switcher-badge {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 0.45rem;
font-weight: 900;
color: #0f172a;
flex-shrink: 0;
}
/* Status indicator for postMessage connection */
.rapp-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: #475569;
flex-shrink: 0;
transition: background 0.3s;
}
.rapp-status.connected {
background: #22c55e;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-rapp": FolkRApp;
}
}
export class FolkRApp extends FolkShape {
static override tagName = "folk-rapp";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#moduleId: string = "";
#spaceSlug: string = "";
#iframe: HTMLIFrameElement | null = null;
#contentEl: HTMLElement | null = null;
#messageHandler: ((e: MessageEvent) => void) | null = null;
#statusEl: HTMLElement | null = null;
get moduleId() { return this.#moduleId; }
set moduleId(value: string) {
if (this.#moduleId === value) return;
this.#moduleId = value;
this.requestUpdate("moduleId");
this.dispatchEvent(new CustomEvent("content-change"));
this.#loadModule();
}
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(value: string) {
if (this.#spaceSlug === value) return;
this.#spaceSlug = value;
this.requestUpdate("spaceSlug");
this.dispatchEvent(new CustomEvent("content-change"));
this.#loadModule();
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#moduleId = this.getAttribute("module-id") || "";
this.#spaceSlug = this.getAttribute("space-slug") || "";
const meta = MODULE_META[this.#moduleId];
const headerColor = meta?.color || "#475569";
const headerName = meta?.name || this.#moduleId || "rApp";
const headerBadge = meta?.badge || "r?";
const headerIcon = meta?.icon || "📱";
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="rapp-header" style="--rapp-color: ${headerColor}">
<div class="rapp-header-left">
<span class="rapp-badge">${headerBadge}</span>
<span class="rapp-name">${headerName}</span>
<span class="rapp-icon">${headerIcon}</span>
<span class="rapp-status" title="Not connected"></span>
<div class="rapp-switcher"></div>
</div>
<div class="rapp-actions">
<button class="switch-btn" title="Switch module">⇄</button>
<button class="open-tab-btn" title="Open in tab">↗</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="rapp-content"></div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement;
this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement;
const switchBtn = wrapper.querySelector(".switch-btn") as HTMLButtonElement;
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const switcherEl = wrapper.querySelector(".rapp-switcher") as HTMLElement;
// Module switcher dropdown
this.#buildSwitcher(switcherEl);
switchBtn.addEventListener("click", (e) => {
e.stopPropagation();
switcherEl.classList.toggle("open");
});
// Close switcher when clicking elsewhere
const closeSwitcher = () => switcherEl.classList.remove("open");
root.addEventListener("click", closeSwitcher);
// Open in tab — navigate to the module's page via tab bar
openTabBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#moduleId && this.#spaceSlug) {
window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId);
}
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Set up postMessage listener
this.#messageHandler = (e: MessageEvent) => this.#handleMessage(e);
window.addEventListener("message", this.#messageHandler);
// Load content
if (this.#moduleId) {
this.#loadModule();
} else {
this.#showPicker();
}
return root;
}
disconnectedCallback() {
super.disconnectedCallback?.();
if (this.#messageHandler) {
window.removeEventListener("message", this.#messageHandler);
this.#messageHandler = null;
}
}
#buildSwitcher(switcherEl: HTMLElement) {
const items = Object.entries(MODULE_META)
.map(([id, meta]) => `
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
<span>${meta.name} ${meta.icon}</span>
</button>
`)
.join("");
switcherEl.innerHTML = items;
switcherEl.querySelectorAll(".rapp-switcher-item").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const modId = (btn as HTMLElement).dataset.module;
if (modId && modId !== this.#moduleId) {
this.moduleId = modId;
this.#buildSwitcher(switcherEl);
}
switcherEl.classList.remove("open");
});
});
}
/** Handle postMessage from embedded iframe */
#handleMessage(e: MessageEvent) {
if (!this.#iframe) return;
// Only accept messages from our iframe
if (e.source !== this.#iframe.contentWindow) return;
const msg = e.data;
if (!msg || typeof msg !== "object") return;
// CommunitySync shape updates from the embedded module
if (msg.source === "rspace-canvas" && msg.type === "shape-updated") {
this.dispatchEvent(new CustomEvent("rapp-data", {
detail: { moduleId: this.#moduleId, shapeId: msg.shapeId, data: msg.data },
bubbles: true,
}));
// Mark as connected
if (this.#statusEl) {
this.#statusEl.classList.add("connected");
this.#statusEl.title = "Connected — receiving data";
}
}
// Navigation request from embedded module
if (msg.source === "rspace-rapp" && msg.type === "navigate" && msg.moduleId) {
this.moduleId = msg.moduleId;
}
}
/** Send context to the iframe after it loads */
#sendContext() {
if (!this.#iframe?.contentWindow) return;
try {
this.#iframe.contentWindow.postMessage({
source: "rspace-parent",
type: "context",
shapeId: this.id,
space: this.#spaceSlug,
moduleId: this.#moduleId,
embedded: true,
}, "*");
} catch {
// cross-origin or iframe not ready
}
}
#loadModule() {
if (!this.#contentEl || !this.#moduleId) return;
// Update header
const meta = MODULE_META[this.#moduleId];
const header = this.shadowRoot?.querySelector(".rapp-header") as HTMLElement;
if (header && meta) {
header.style.setProperty("--rapp-color", meta.color);
const badge = header.querySelector(".rapp-badge");
const name = header.querySelector(".rapp-name");
const icon = header.querySelector(".rapp-icon");
if (badge) badge.textContent = meta.badge;
if (name) name.textContent = meta.name;
if (icon) icon.textContent = meta.icon;
}
// Reset connection status
if (this.#statusEl) {
this.#statusEl.classList.remove("connected");
this.#statusEl.title = "Loading...";
}
// Show loading state
this.#contentEl.innerHTML = `
<div class="rapp-loading">
<div class="spinner"></div>
<span>Loading ${meta?.name || this.#moduleId}...</span>
</div>
`;
// Create iframe
const space = this.#spaceSlug || "demo";
const iframeUrl = `/${space}/${this.#moduleId}`;
const iframe = document.createElement("iframe");
iframe.className = "rapp-iframe";
iframe.src = iframeUrl;
iframe.loading = "lazy";
iframe.allow = "clipboard-write";
iframe.addEventListener("load", () => {
// Remove loading indicator
const loading = this.#contentEl?.querySelector(".rapp-loading");
if (loading) loading.remove();
// Send context to the newly loaded iframe
this.#sendContext();
});
iframe.addEventListener("error", () => {
if (this.#contentEl) {
this.#contentEl.innerHTML = `
<div class="rapp-error">
<span>Failed to load ${meta?.name || this.#moduleId}</span>
<button class="rapp-picker-item" style="justify-content: center; color: #94a3b8;">
Retry
</button>
</div>
`;
this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadModule());
}
});
this.#contentEl.appendChild(iframe);
this.#iframe = iframe;
}
#showPicker() {
if (!this.#contentEl) return;
const items = Object.entries(MODULE_META)
.map(([id, meta]) => `
<button class="rapp-picker-item" data-module="${id}">
<span class="rapp-picker-badge" style="background: ${meta.color}">${meta.badge}</span>
<span>${meta.name}</span>
<span>${meta.icon}</span>
</button>
`)
.join("");
this.#contentEl.innerHTML = `
<div class="rapp-picker">
<span class="rapp-picker-title">Choose an rApp to embed</span>
${items}
</div>
`;
this.#contentEl.querySelectorAll(".rapp-picker-item").forEach((btn) => {
btn.addEventListener("click", () => {
const modId = (btn as HTMLElement).dataset.module;
if (modId) {
this.moduleId = modId;
}
});
});
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-rapp",
moduleId: this.#moduleId,
spaceSlug: this.#spaceSlug,
};
}
}