/** * — Inline module configuration card. * * Renders a module's settingsSchema form directly in context (e.g. inside * an unconfigured newsletter manager) so admins can configure without * navigating to space settings. * * Attributes: space, module-id, role * Dispatches: module-configured (bubbles, composed) on successful save. */ const SESSION_KEY = "encryptid_session"; interface SettingField { key: string; label: string; type: string; description?: string; default?: string | boolean; options?: Array<{ value: string; label: string }>; } interface ModConfig { id: string; name: string; icon: string; settingsSchema?: SettingField[]; settings?: Record; } function getToken(): string | null { try { const raw = localStorage.getItem(SESSION_KEY); if (!raw) return null; return JSON.parse(raw)?.accessToken || null; } catch { return null; } } export class RStackModuleSetup extends HTMLElement { private _space = ""; private _moduleId = ""; private _role = "viewer"; private _schema: SettingField[] = []; private _values: Record = {}; private _moduleName = ""; private _moduleIcon = ""; private _loading = true; private _saving = false; private _error = ""; private _success = false; static define() { if (!customElements.get("rstack-module-setup")) { customElements.define("rstack-module-setup", RStackModuleSetup); } } static get observedAttributes() { return ["space", "module-id", "role"]; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "space") this._space = val; else if (name === "module-id") this._moduleId = val; else if (name === "role") this._role = val; } connectedCallback() { this._space = this.getAttribute("space") || ""; this._moduleId = this.getAttribute("module-id") || ""; this._role = this.getAttribute("role") || "viewer"; if (!this.shadowRoot) this.attachShadow({ mode: "open" }); this._loadSchema(); } private get _isAdmin(): boolean { return this._role === "admin"; } private async _loadSchema() { this._loading = true; this._render(); if (!this._space || !this._moduleId) { this._loading = false; this._render(); return; } const token = getToken(); try { const res = await fetch(`/api/spaces/${encodeURIComponent(this._space)}/modules`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) throw new Error("Failed to load modules"); const data = await res.json(); const mod: ModConfig | undefined = (data.modules || []).find((m: ModConfig) => m.id === this._moduleId); if (mod?.settingsSchema) { this._schema = mod.settingsSchema; this._moduleName = mod.name; this._moduleIcon = mod.icon; // Populate current values this._values = {}; for (const field of this._schema) { this._values[field.key] = mod.settings?.[field.key] ?? field.default ?? ""; } } } catch (e: any) { this._error = e.message || "Failed to load settings"; } this._loading = false; this._render(); } private _render() { if (!this.shadowRoot) return; if (this._loading) { this.shadowRoot.innerHTML = `
Loading configuration...
`; return; } if (this._success) { this.shadowRoot.innerHTML = `
Configuration saved!
`; return; } if (this._schema.length === 0) { this.shadowRoot.innerHTML = `
No settings available for this module.
`; return; } if (!this._isAdmin) { this.shadowRoot.innerHTML = `
${this._moduleIcon} ${this._esc(this._moduleName)} Configuration Required

A space admin needs to configure ${this._esc(this._moduleName)} before it can be used.

`; return; } const fieldsHTML = this._schema.map(field => { const val = this._values[field.key] ?? ""; let inputHTML = ""; if (field.type === "boolean") { inputHTML = ``; } else if (field.type === "select") { inputHTML = ``; } else if (field.type === "password") { inputHTML = ``; } else { inputHTML = ``; } return `
${inputHTML} ${field.description ? `
${this._esc(field.description)}
` : ""}
`; }).join(""); this.shadowRoot.innerHTML = `
${this._moduleIcon} Configure ${this._esc(this._moduleName)}
${fieldsHTML}
${this._error ? `
${this._esc(this._error)}
` : ""}
`; this._bindEvents(); } private _bindEvents() { const sr = this.shadowRoot!; sr.getElementById("save-btn")?.addEventListener("click", () => this._save()); // Track field changes sr.querySelectorAll(".setup-field").forEach(el => { el.addEventListener("change", () => { const input = el as HTMLInputElement | HTMLSelectElement; const key = input.dataset.key!; if (input.type === "checkbox") { this._values[key] = (input as HTMLInputElement).checked; } else { this._values[key] = input.value; } }); el.addEventListener("input", () => { const input = el as HTMLInputElement; const key = input.dataset.key!; if (input.type !== "checkbox") { this._values[key] = input.value; } }); }); } private async _save() { this._saving = true; this._error = ""; this._render(); const token = getToken(); if (!token) { this._error = "Not authenticated"; this._saving = false; this._render(); return; } try { const res = await fetch(`/api/spaces/${encodeURIComponent(this._space)}/modules`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ moduleSettings: { [this._moduleId]: this._values }, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `Save failed (${res.status})`); } this._success = true; this._render(); // Dispatch event so parent components can react this.dispatchEvent(new CustomEvent("module-configured", { bubbles: true, composed: true, detail: { moduleId: this._moduleId, space: this._space }, })); } catch (e: any) { this._error = e.message || "Failed to save"; this._saving = false; this._render(); } } private _esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } private _escAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """); } } const SETUP_CSS = ` :host { display: block; } .setup-card { max-width: 480px; margin: 2rem auto; padding: 24px; background: var(--rs-bg-surface, #1e1e2e); border: 1px solid var(--rs-border, #333); border-radius: 12px; } .setup-card.success { text-align: center; padding: 32px 24px; } .success-icon { font-size: 2.5rem; color: #14b8a6; margin-bottom: 8px; } .success-text { font-size: 1rem; color: var(--rs-text-primary, #e5e5e5); font-weight: 500; } .setup-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; } .setup-icon { font-size: 1.5rem; } .setup-title { font-size: 1.05rem; font-weight: 600; color: var(--rs-text-primary, #e5e5e5); } .setup-desc { font-size: 0.85rem; color: var(--rs-text-secondary, #aaa); margin: 0; line-height: 1.5; } .setup-fields { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; } .field-group { display: flex; flex-direction: column; gap: 4px; } .field-label { font-size: 0.78rem; font-weight: 600; color: var(--rs-text-secondary, #a3a3a3); } .input { background: var(--rs-border-subtle, #0a0a0a); border: 1px solid var(--rs-border, #404040); color: var(--rs-text-primary, #e5e5e5); border-radius: 8px; padding: 8px 12px; font-size: 0.85rem; width: 100%; box-sizing: border-box; } .input:focus { outline: none; border-color: #14b8a6; } .toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; } .toggle-label { font-size: 0.82rem; color: var(--rs-text-secondary, #aaa); } .field-desc { font-size: 0.7rem; color: var(--rs-text-muted, #525252); line-height: 1.4; } .error-msg { font-size: 0.78rem; color: #f87171; margin-bottom: 12px; } .save-btn { width: 100%; padding: 10px 16px; background: linear-gradient(135deg, #14b8a6, #0d9488); border: none; color: white; border-radius: 8px; font-size: 0.88rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; } .save-btn:hover { opacity: 0.9; } .save-btn:disabled { opacity: 0.5; cursor: not-allowed; } .loading, .empty { text-align: center; padding: 16px 0; font-size: 0.85rem; color: var(--rs-text-muted, #737373); } `;