From b2347ec418cf93f487fc5005a5a75d1292252bce Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 16:45:48 -0700 Subject: [PATCH] feat: per-rApp inline config + module-aware settings panel Add component for inline module configuration (replaces static "Not Configured" instructions). Enhance settings gear panel to show the current module's settingsSchema at the top. Pass module-id through shell rendering and tab-cache switching. Co-Authored-By: Claude Opus 4.6 --- .../components/folk-newsletter-manager.ts | 21 +- server/shell.ts | 5 +- shared/components/rstack-module-setup.ts | 400 ++++++++++++++++++ shared/components/rstack-space-settings.ts | 193 ++++++++- website/shell.ts | 2 + 5 files changed, 606 insertions(+), 15 deletions(-) create mode 100644 shared/components/rstack-module-setup.ts diff --git a/modules/rsocials/components/folk-newsletter-manager.ts b/modules/rsocials/components/folk-newsletter-manager.ts index 116d75d..3057322 100644 --- a/modules/rsocials/components/folk-newsletter-manager.ts +++ b/modules/rsocials/components/folk-newsletter-manager.ts @@ -34,6 +34,14 @@ export class FolkNewsletterManager extends HTMLElement { this._role = this.getAttribute('role') || 'viewer'; this.render(); this.checkStatus(); + + // Re-check status when module is configured inline + this.addEventListener('module-configured', () => { + this._configured = false; + this._loading = true; + this.render(); + this.checkStatus(); + }); } attributeChangedCallback(name: string, _old: string, val: string) { @@ -177,18 +185,7 @@ export class FolkNewsletterManager extends HTMLElement { } private renderSetup(): string { - return ` -
-

Newsletter Not Configured

-

Connect your Listmonk instance to manage newsletters from here.

-
    -
  1. Open the space settings panel (gear icon in the top bar)
  2. -
  3. Find rSocials and click the settings gear
  4. -
  5. Enter your Listmonk URL, username, and password
  6. -
  7. Click Save Module Config
  8. -
-
- `; + return ``; } private renderTabContent(): string { diff --git a/server/shell.ts b/server/shell.ts index 4c91ddc..b368c71 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -141,7 +141,7 @@ export function renderShell(opts: ShellOptions): string { - +
@@ -386,6 +386,9 @@ export function renderShell(opts: ShellOptions): string { tabBar.addEventListener('layer-switch', (e) => { const { layerId, moduleId } = e.detail; saveTabs(); + // Update settings panel to show config for the newly active module + const sp = document.querySelector('rstack-space-settings'); + if (sp) sp.setAttribute('module-id', moduleId); if (tabCache) { tabCache.switchTo(moduleId).then(ok => { if (ok) { diff --git a/shared/components/rstack-module-setup.ts b/shared/components/rstack-module-setup.ts new file mode 100644 index 0000000..4804842 --- /dev/null +++ b/shared/components/rstack-module-setup.ts @@ -0,0 +1,400 @@ +/** + * — 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); +} +`; diff --git a/shared/components/rstack-space-settings.ts b/shared/components/rstack-space-settings.ts index bbe0483..98188a8 100644 --- a/shared/components/rstack-space-settings.ts +++ b/shared/components/rstack-space-settings.ts @@ -14,6 +14,23 @@ interface MemberInfo { joinedAt?: number; } +interface SettingField { + key: string; + label: string; + type: string; + description?: string; + default?: string | boolean; + options?: Array<{ value: string; label: string }>; +} + +interface ModuleConfig { + id: string; + name: string; + icon: string; + settingsSchema?: SettingField[]; + settings?: Record; +} + function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null { try { const raw = localStorage.getItem(SESSION_KEY); @@ -37,6 +54,11 @@ export class RStackSpaceSettings extends HTMLElement { private _addMode: "username" | "email" = "username"; private _lookupResult: { did: string; username: string; displayName: string } | null = null; private _lookupError = ""; + private _moduleId = ""; + private _moduleConfig: ModuleConfig | null = null; + private _moduleSettingsValues: Record = {}; + private _moduleSaveError = ""; + private _moduleSaving = false; static define() { if (!customElements.get("rstack-space-settings")) { @@ -44,14 +66,20 @@ export class RStackSpaceSettings extends HTMLElement { } } - static get observedAttributes() { return ["space"]; } + static get observedAttributes() { return ["space", "module-id"]; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "space") this._space = val; + else if (name === "module-id") { + this._moduleId = val || ""; + // Reload module config if panel is open + if (this._open) this._loadModuleConfig(); + } } connectedCallback() { this._space = this.getAttribute("space") || ""; + this._moduleId = this.getAttribute("module-id") || ""; if (!this.shadowRoot) this.attachShadow({ mode: "open" }); this._render(); } @@ -59,6 +87,7 @@ export class RStackSpaceSettings extends HTMLElement { open() { this._open = true; this._loadData(); + this._loadModuleConfig(); this._render(); } @@ -136,6 +165,68 @@ export class RStackSpaceSettings extends HTMLElement { this._render(); } + private async _loadModuleConfig() { + if (!this._space || !this._moduleId) { + this._moduleConfig = null; + return; + } + + const token = getToken(); + try { + const res = await fetch(`/api/spaces/${encodeURIComponent(this._space)}/modules`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) return; + const data = await res.json(); + const mod: ModuleConfig | undefined = (data.modules || []).find((m: ModuleConfig) => m.id === this._moduleId); + if (mod?.settingsSchema && mod.settingsSchema.length > 0) { + this._moduleConfig = mod; + this._moduleSettingsValues = {}; + for (const field of mod.settingsSchema) { + this._moduleSettingsValues[field.key] = mod.settings?.[field.key] ?? field.default ?? ""; + } + } else { + this._moduleConfig = null; + } + } catch { + this._moduleConfig = null; + } + this._render(); + } + + private async _saveModuleSettings() { + if (!this._moduleConfig || !this._moduleId) return; + const token = getToken(); + if (!token) return; + + this._moduleSaving = true; + this._moduleSaveError = ""; + this._render(); + + 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._moduleSettingsValues }, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Save failed (${res.status})`); + } + // Reload to reflect saved state + await this._loadModuleConfig(); + } catch (e: any) { + this._moduleSaveError = e.message || "Failed to save"; + } + this._moduleSaving = false; + this._render(); + } + private get _isAdmin(): boolean { return this._isOwner || this._myRole === "admin"; } @@ -199,16 +290,61 @@ export class RStackSpaceSettings extends HTMLElement { `; }).join("") || '
No pending invites
'; + // Module-specific settings section + let moduleSettingsHTML = ""; + if (this._moduleConfig && this._isAdmin) { + const fields = this._moduleConfig.settingsSchema!; + const fieldsHTML = fields.map(field => { + const val = this._moduleSettingsValues[field.key] ?? ""; + let inputHTML = ""; + + if (field.type === "boolean") { + inputHTML = ``; + } else if (field.type === "select") { + inputHTML = ``; + } else if (field.type === "password") { + inputHTML = ``; + } else { + inputHTML = ``; + } + + return ` +
+ ${field.type !== "boolean" ? `` : ""} + ${inputHTML} + ${field.description ? `
${this._esc(field.description)}
` : ""} +
+ `; + }).join(""); + + moduleSettingsHTML = ` +
+

${this._moduleConfig.icon} ${this._esc(this._moduleConfig.name)} Settings

+
${fieldsHTML}
+ ${this._moduleSaveError ? `
${this._esc(this._moduleSaveError)}
` : ""} + +
+ `; + } + + const panelTitle = this._moduleConfig + ? `${this._moduleConfig.icon} ${this._esc(this._moduleConfig.name)}` + : "Space Settings"; + this.shadowRoot.innerHTML = `
-

Space Settings

+

${panelTitle}

+ ${moduleSettingsHTML} +

Members ${this._members.length}

${membersHTML}
@@ -322,6 +458,23 @@ export class RStackSpaceSettings extends HTMLElement { this._revokeInvite(id); }); }); + + // Module config fields + sr.querySelectorAll(".mod-cfg-field").forEach(el => { + const handler = () => { + const input = el as HTMLInputElement | HTMLSelectElement; + const key = input.dataset.key!; + if (input.type === "checkbox") { + this._moduleSettingsValues[key] = (input as HTMLInputElement).checked; + } else { + this._moduleSettingsValues[key] = input.value; + } + }; + el.addEventListener("change", handler); + el.addEventListener("input", handler); + }); + + sr.getElementById("mod-cfg-save")?.addEventListener("click", () => this._saveModuleSettings()); } private async _lookupUser(username: string) { @@ -771,4 +924,40 @@ const PANEL_CSS = ` padding: 12px 0; text-align: center; } + +/* Module config */ +.mod-cfg-section { + border-bottom: 1px solid var(--rs-btn-secondary-bg); + padding-bottom: 16px; +} + +.mod-cfg-fields { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.mod-cfg-field-group { + display: flex; + flex-direction: column; + gap: 3px; +} + +.mod-cfg-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--rs-text-secondary); +} + +.mod-cfg-desc { + font-size: 0.68rem; + color: var(--rs-text-muted); + line-height: 1.4; +} + +.mod-cfg-save { + width: 100%; + margin-top: 4px; +} `; diff --git a/website/shell.ts b/website/shell.ts index be451a0..c7f58c4 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -14,6 +14,7 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher" import { RStackTabBar } from "../shared/components/rstack-tab-bar"; import { RStackMi } from "../shared/components/rstack-mi"; 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 { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; @@ -35,6 +36,7 @@ RStackSpaceSwitcher.define(); RStackTabBar.define(); RStackMi.define(); RStackSpaceSettings.define(); +RStackModuleSetup.define(); RStackHistoryPanel.define(); RStackOfflineIndicator.define(); RStackUserDashboard.define();