feat: per-rApp inline config + module-aware settings panel
Add <rstack-module-setup> 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 <noreply@anthropic.com>
This commit is contained in:
parent
bb432a6af1
commit
b2347ec418
|
|
@ -34,6 +34,14 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
this._role = this.getAttribute('role') || 'viewer';
|
this._role = this.getAttribute('role') || 'viewer';
|
||||||
this.render();
|
this.render();
|
||||||
this.checkStatus();
|
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) {
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||||
|
|
@ -177,18 +185,7 @@ export class FolkNewsletterManager extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSetup(): string {
|
private renderSetup(): string {
|
||||||
return `
|
return `<rstack-module-setup space="${this.esc(this._space)}" module-id="rsocials" role="${this.esc(this._role)}"></rstack-module-setup>`;
|
||||||
<div class="nl-setup">
|
|
||||||
<h3>Newsletter Not Configured</h3>
|
|
||||||
<p>Connect your Listmonk instance to manage newsletters from here.</p>
|
|
||||||
<ol class="nl-setup-steps">
|
|
||||||
<li>Open the space settings panel (gear icon in the top bar)</li>
|
|
||||||
<li>Find <strong>rSocials</strong> and click the settings gear</li>
|
|
||||||
<li>Enter your Listmonk URL, username, and password</li>
|
|
||||||
<li>Click <strong>Save Module Config</strong></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderTabContent(): string {
|
private renderTabContent(): string {
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
<rstack-identity></rstack-identity>
|
<rstack-identity></rstack-identity>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<rstack-space-settings space="${escapeAttr(spaceSlug)}"></rstack-space-settings>
|
<rstack-space-settings space="${escapeAttr(spaceSlug)}" module-id="${escapeAttr(moduleId)}"></rstack-space-settings>
|
||||||
<rstack-history-panel></rstack-history-panel>
|
<rstack-history-panel></rstack-history-panel>
|
||||||
<div class="rstack-tab-row">
|
<div class="rstack-tab-row">
|
||||||
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
|
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
|
||||||
|
|
@ -386,6 +386,9 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
tabBar.addEventListener('layer-switch', (e) => {
|
tabBar.addEventListener('layer-switch', (e) => {
|
||||||
const { layerId, moduleId } = e.detail;
|
const { layerId, moduleId } = e.detail;
|
||||||
saveTabs();
|
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) {
|
if (tabCache) {
|
||||||
tabCache.switchTo(moduleId).then(ok => {
|
tabCache.switchTo(moduleId).then(ok => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
/**
|
||||||
|
* <rstack-module-setup> — 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<string, string | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string | boolean> = {};
|
||||||
|
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 = `<style>${SETUP_CSS}</style><div class="setup-card"><div class="loading">Loading configuration...</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._success) {
|
||||||
|
this.shadowRoot.innerHTML = `<style>${SETUP_CSS}</style><div class="setup-card success"><div class="success-icon">✓</div><div class="success-text">Configuration saved!</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._schema.length === 0) {
|
||||||
|
this.shadowRoot.innerHTML = `<style>${SETUP_CSS}</style><div class="setup-card"><div class="empty">No settings available for this module.</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._isAdmin) {
|
||||||
|
this.shadowRoot.innerHTML = `<style>${SETUP_CSS}</style>
|
||||||
|
<div class="setup-card">
|
||||||
|
<div class="setup-header">
|
||||||
|
<span class="setup-icon">${this._moduleIcon}</span>
|
||||||
|
<span class="setup-title">${this._esc(this._moduleName)} Configuration Required</span>
|
||||||
|
</div>
|
||||||
|
<p class="setup-desc">A space admin needs to configure ${this._esc(this._moduleName)} before it can be used.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsHTML = this._schema.map(field => {
|
||||||
|
const val = this._values[field.key] ?? "";
|
||||||
|
let inputHTML = "";
|
||||||
|
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
inputHTML = `<label class="toggle"><input type="checkbox" class="setup-field" data-key="${field.key}" ${val ? "checked" : ""} /><span class="toggle-label">Enabled</span></label>`;
|
||||||
|
} else if (field.type === "select") {
|
||||||
|
inputHTML = `<select class="setup-field input" data-key="${field.key}">${(field.options || []).map(o => `<option value="${o.value}" ${val === o.value ? "selected" : ""}>${o.label}</option>`).join("")}</select>`;
|
||||||
|
} else if (field.type === "password") {
|
||||||
|
inputHTML = `<input type="password" class="setup-field input" data-key="${field.key}" value="${typeof val === "string" ? this._escAttr(val) : ""}" autocomplete="off" />`;
|
||||||
|
} else {
|
||||||
|
inputHTML = `<input type="text" class="setup-field input" data-key="${field.key}" value="${typeof val === "string" ? this._escAttr(val) : ""}" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">${this._esc(field.label)}</label>
|
||||||
|
${inputHTML}
|
||||||
|
${field.description ? `<div class="field-desc">${this._esc(field.description)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>${SETUP_CSS}</style>
|
||||||
|
<div class="setup-card">
|
||||||
|
<div class="setup-header">
|
||||||
|
<span class="setup-icon">${this._moduleIcon}</span>
|
||||||
|
<span class="setup-title">Configure ${this._esc(this._moduleName)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="setup-fields">${fieldsHTML}</div>
|
||||||
|
${this._error ? `<div class="error-msg">${this._esc(this._error)}</div>` : ""}
|
||||||
|
<button class="save-btn" id="save-btn" ${this._saving ? "disabled" : ""}>
|
||||||
|
${this._saving ? "Saving..." : "Save & Continue"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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, ">").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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -14,6 +14,23 @@ interface MemberInfo {
|
||||||
joinedAt?: number;
|
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<string, string | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null {
|
function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_KEY);
|
const raw = localStorage.getItem(SESSION_KEY);
|
||||||
|
|
@ -37,6 +54,11 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
private _addMode: "username" | "email" = "username";
|
private _addMode: "username" | "email" = "username";
|
||||||
private _lookupResult: { did: string; username: string; displayName: string } | null = null;
|
private _lookupResult: { did: string; username: string; displayName: string } | null = null;
|
||||||
private _lookupError = "";
|
private _lookupError = "";
|
||||||
|
private _moduleId = "";
|
||||||
|
private _moduleConfig: ModuleConfig | null = null;
|
||||||
|
private _moduleSettingsValues: Record<string, string | boolean> = {};
|
||||||
|
private _moduleSaveError = "";
|
||||||
|
private _moduleSaving = false;
|
||||||
|
|
||||||
static define() {
|
static define() {
|
||||||
if (!customElements.get("rstack-space-settings")) {
|
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) {
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||||
if (name === "space") this._space = val;
|
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() {
|
connectedCallback() {
|
||||||
this._space = this.getAttribute("space") || "";
|
this._space = this.getAttribute("space") || "";
|
||||||
|
this._moduleId = this.getAttribute("module-id") || "";
|
||||||
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +87,7 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
open() {
|
open() {
|
||||||
this._open = true;
|
this._open = true;
|
||||||
this._loadData();
|
this._loadData();
|
||||||
|
this._loadModuleConfig();
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +165,68 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
this._render();
|
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 {
|
private get _isAdmin(): boolean {
|
||||||
return this._isOwner || this._myRole === "admin";
|
return this._isOwner || this._myRole === "admin";
|
||||||
}
|
}
|
||||||
|
|
@ -199,16 +290,61 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
}).join("") || '<div class="empty-state">No pending invites</div>';
|
}).join("") || '<div class="empty-state">No pending invites</div>';
|
||||||
|
|
||||||
|
// 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 = `<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" class="mod-cfg-field" data-key="${field.key}" ${val ? "checked" : ""} /><span style="font-size:0.82rem;color:var(--rs-text-secondary)">${this._esc(field.label)}</span></label>`;
|
||||||
|
} else if (field.type === "select") {
|
||||||
|
inputHTML = `<select class="mod-cfg-field input" data-key="${field.key}">${(field.options || []).map(o => `<option value="${o.value}" ${val === o.value ? "selected" : ""}>${o.label}</option>`).join("")}</select>`;
|
||||||
|
} else if (field.type === "password") {
|
||||||
|
inputHTML = `<input type="password" class="mod-cfg-field input" data-key="${field.key}" value="${typeof val === "string" ? this._esc(val) : ""}" autocomplete="off" />`;
|
||||||
|
} else {
|
||||||
|
inputHTML = `<input type="text" class="mod-cfg-field input" data-key="${field.key}" value="${typeof val === "string" ? this._esc(val) : ""}" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mod-cfg-field-group">
|
||||||
|
${field.type !== "boolean" ? `<label class="mod-cfg-label">${this._esc(field.label)}</label>` : ""}
|
||||||
|
${inputHTML}
|
||||||
|
${field.description ? `<div class="mod-cfg-desc">${this._esc(field.description)}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
moduleSettingsHTML = `
|
||||||
|
<section class="section mod-cfg-section">
|
||||||
|
<h3>${this._moduleConfig.icon} ${this._esc(this._moduleConfig.name)} Settings</h3>
|
||||||
|
<div class="mod-cfg-fields">${fieldsHTML}</div>
|
||||||
|
${this._moduleSaveError ? `<div class="error-msg">${this._esc(this._moduleSaveError)}</div>` : ""}
|
||||||
|
<button class="add-btn mod-cfg-save" id="mod-cfg-save" ${this._moduleSaving ? "disabled" : ""}>
|
||||||
|
${this._moduleSaving ? "Saving..." : "Save Module Settings"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelTitle = this._moduleConfig
|
||||||
|
? `${this._moduleConfig.icon} ${this._esc(this._moduleConfig.name)}`
|
||||||
|
: "Space Settings";
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>${PANEL_CSS}</style>
|
<style>${PANEL_CSS}</style>
|
||||||
<div class="overlay" id="overlay"></div>
|
<div class="overlay" id="overlay"></div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Space Settings</h2>
|
<h2>${panelTitle}</h2>
|
||||||
<button class="close-btn" id="close-btn">×</button>
|
<button class="close-btn" id="close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
|
${moduleSettingsHTML}
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h3>Members <span class="count">${this._members.length}</span></h3>
|
<h3>Members <span class="count">${this._members.length}</span></h3>
|
||||||
<div class="members-list">${membersHTML}</div>
|
<div class="members-list">${membersHTML}</div>
|
||||||
|
|
@ -322,6 +458,23 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
this._revokeInvite(id);
|
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) {
|
private async _lookupUser(username: string) {
|
||||||
|
|
@ -771,4 +924,40 @@ const PANEL_CSS = `
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
text-align: center;
|
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;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher"
|
||||||
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
||||||
import { RStackMi } from "../shared/components/rstack-mi";
|
import { RStackMi } from "../shared/components/rstack-mi";
|
||||||
import { RStackSpaceSettings } from "../shared/components/rstack-space-settings";
|
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 { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
|
||||||
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
|
||||||
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard";
|
||||||
|
|
@ -35,6 +36,7 @@ RStackSpaceSwitcher.define();
|
||||||
RStackTabBar.define();
|
RStackTabBar.define();
|
||||||
RStackMi.define();
|
RStackMi.define();
|
||||||
RStackSpaceSettings.define();
|
RStackSpaceSettings.define();
|
||||||
|
RStackModuleSetup.define();
|
||||||
RStackHistoryPanel.define();
|
RStackHistoryPanel.define();
|
||||||
RStackOfflineIndicator.define();
|
RStackOfflineIndicator.define();
|
||||||
RStackUserDashboard.define();
|
RStackUserDashboard.define();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue