rspace-online/lib/folk-rapp.ts

417 lines
11 KiB
TypeScript

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.
*/
// 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: "🔮" },
rproviders: { badge: "rPr", color: "#fdba74", name: "rProviders", 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;
}
.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;
}
`;
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;
get moduleId() { return this.#moduleId; }
set moduleId(value: string) {
this.#moduleId = value;
this.requestUpdate("moduleId");
this.dispatchEvent(new CustomEvent("content-change"));
this.#loadModule();
}
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(value: string) {
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>
</div>
<div class="rapp-actions">
<button class="open-tab-btn" title="Open in tab">↗</button>
<button class="close-btn" title="Close">\u00D7</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;
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Open in tab navigates to the module's page
openTabBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#moduleId && this.#spaceSlug) {
window.open(rspaceNavUrl(this.#spaceSlug, this.#moduleId), "_blank");
}
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Load content
if (this.#moduleId) {
this.#loadModule();
} else {
this.#showPicker();
}
return root;
}
#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;
}
// 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();
});
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,
};
}
}