feat(shell): add "Manage rApps" catalog to app switcher sidebar

Extends <rstack-app-switcher> with an expandable "Manage rApps" panel
at the bottom of the sidebar. Space owners can:
- See all available modules (enabled + disabled) in one place
- Toggle modules on/off with + / − buttons
- Changes persist via PATCH /api/spaces/:slug/modules
- Local toggle fallback for demo mode
- Busy state disables buttons during API calls

Shell changes:
- renderShell() now builds allModulesJSON with `enabled` flags
- Calls setAllModules() on the app switcher alongside setModules()
- Dispatches 'modules-changed' event for shell reactivity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 17:20:31 -07:00
parent c233f1338b
commit 668c239cf3
2 changed files with 232 additions and 1 deletions

View File

@ -102,6 +102,11 @@ export function renderShell(opts: ShellOptions): string {
? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id)) ? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id))
: modules; : modules;
const moduleListJSON = JSON.stringify(visibleModules); const moduleListJSON = JSON.stringify(visibleModules);
// Full catalog with enabled flags for "Manage rApps" panel
const allModulesJSON = JSON.stringify(modules.map(m => ({
...m,
enabled: !enabledModules || enabledModules.includes(m.id) || m.id === "rspace",
})));
const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`; const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
return versionAssetUrls(`<!DOCTYPE html> return versionAssetUrls(`<!DOCTYPE html>
@ -352,7 +357,10 @@ export function renderShell(opts: ShellOptions): string {
(function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})(); (function(){try{var t=localStorage.getItem('canvas-theme');if(t)document.querySelectorAll('[data-theme]').forEach(function(el){el.setAttribute('data-theme',t)})}catch(e){}})();
// Provide module list to app switcher and offline runtime // Provide module list to app switcher and offline runtime
window.__rspaceModuleList = ${moduleListJSON}; window.__rspaceModuleList = ${moduleListJSON};
document.querySelector('rstack-app-switcher')?.setModules(window.__rspaceModuleList); window.__rspaceAllModules = ${allModulesJSON};
const _switcher = document.querySelector('rstack-app-switcher');
_switcher?.setModules(window.__rspaceModuleList);
_switcher?.setAllModules(window.__rspaceAllModules);
// ── "Try Demo" button visibility ── // ── "Try Demo" button visibility ──
// Hidden when logged in. When logged out, shown everywhere except demo.rspace.online // Hidden when logged in. When logged out, shown everywhere except demo.rspace.online

View File

@ -15,6 +15,7 @@ export interface AppSwitcherModule {
description: string; description: string;
standaloneDomain?: string; standaloneDomain?: string;
scoping?: { defaultScope: 'space' | 'global'; userConfigurable: boolean }; scoping?: { defaultScope: 'space' | 'global'; userConfigurable: boolean };
enabled?: boolean;
} }
// Pastel badge abbreviations & colors for each module // Pastel badge abbreviations & colors for each module
@ -112,7 +113,10 @@ import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helper
export class RStackAppSwitcher extends HTMLElement { export class RStackAppSwitcher extends HTMLElement {
#shadow: ShadowRoot; #shadow: ShadowRoot;
#modules: AppSwitcherModule[] = []; #modules: AppSwitcherModule[] = [];
#allModules: AppSwitcherModule[] = []; // Full catalog including disabled
#isOpen = false; #isOpen = false;
#catalogOpen = false;
#catalogBusy = false;
#outsideClickHandler: ((e: MouseEvent) => void) | null = null; #outsideClickHandler: ((e: MouseEvent) => void) | null = null;
constructor() { constructor() {
@ -168,6 +172,11 @@ export class RStackAppSwitcher extends HTMLElement {
this.#render(); this.#render();
} }
/** Provide the full module catalog (enabled + disabled) for the "Manage rApps" panel */
setAllModules(modules: AppSwitcherModule[]) {
this.#allModules = modules;
}
#renderGroupedModules(current: string): string { #renderGroupedModules(current: string): string {
// Group modules by category // Group modules by category
const groups = new Map<string, AppSwitcherModule[]>(); const groups = new Map<string, AppSwitcherModule[]>();
@ -215,6 +224,42 @@ export class RStackAppSwitcher extends HTMLElement {
html += uncategorized.map((m) => this.#renderItem(m, current)).join(""); html += uncategorized.map((m) => this.#renderItem(m, current)).join("");
} }
// "Manage rApps" catalog section
const disabledModules = this.#allModules.filter(
m => m.enabled === false && m.id !== 'rspace'
);
if (disabledModules.length > 0 || this.#allModules.length > 0) {
html += `
<div class="catalog-divider">
<button class="catalog-toggle" id="catalog-toggle">
${this.#catalogOpen ? '▾' : '▸'} Manage rApps
${disabledModules.length > 0 ? `<span class="catalog-count">${disabledModules.length} available</span>` : ''}
</button>
</div>
`;
if (this.#catalogOpen) {
html += `<div class="catalog-panel" id="catalog-panel">`;
// Show enabled modules with toggle-off option
const enabledNonCore = this.#allModules.filter(
m => m.enabled !== false && m.id !== 'rspace'
);
if (enabledNonCore.length > 0) {
html += `<div class="catalog-section-label">Enabled</div>`;
for (const m of enabledNonCore) {
html += this.#renderCatalogItem(m, true);
}
}
// Show disabled modules with toggle-on option
if (disabledModules.length > 0) {
html += `<div class="catalog-section-label">Available to Add</div>`;
for (const m of disabledModules) {
html += this.#renderCatalogItem(m, false);
}
}
html += `</div>`;
}
}
// Footer // Footer
html += ` html += `
<div class="rstack-footer"> <div class="rstack-footer">
@ -225,6 +270,30 @@ export class RStackAppSwitcher extends HTMLElement {
return html; return html;
} }
#renderCatalogItem(m: AppSwitcherModule, enabled: boolean): string {
const badgeInfo = MODULE_BADGES[m.id];
const badgeHtml = badgeInfo
? `<span class="item-badge" style="background:${badgeInfo.color}${enabled ? '' : ';opacity:0.5'}">${badgeInfo.badge}</span>`
: `<span class="item-icon" style="${enabled ? '' : 'opacity:0.5'}">${m.icon}</span>`;
const cat = MODULE_CATEGORIES[m.id] || '';
return `
<div class="catalog-item ${enabled ? '' : 'catalog-item--disabled'}">
${badgeHtml}
<div class="item-text" style="flex:1;min-width:0;">
<span class="item-name">${m.name}</span>
<span class="item-desc">${m.description}</span>
</div>
<button class="catalog-btn ${enabled ? 'catalog-btn--remove' : 'catalog-btn--add'}"
data-catalog-toggle="${m.id}" data-catalog-enabled="${enabled}"
title="${enabled ? 'Remove from space' : 'Add to space'}"
${this.#catalogBusy ? 'disabled' : ''}>
${enabled ? '' : '+'}
</button>
</div>
`;
}
#renderItem(m: AppSwitcherModule, current: string): string { #renderItem(m: AppSwitcherModule, current: string): string {
const badgeInfo = MODULE_BADGES[m.id]; const badgeInfo = MODULE_BADGES[m.id];
const badgeHtml = badgeInfo const badgeHtml = badgeInfo
@ -357,6 +426,106 @@ export class RStackAppSwitcher extends HTMLElement {
})); }));
}); });
}); });
// Catalog toggle
this.#shadow.getElementById("catalog-toggle")?.addEventListener("click", () => {
this.#catalogOpen = !this.#catalogOpen;
this.#render();
});
// Catalog enable/disable buttons
this.#shadow.querySelectorAll("[data-catalog-toggle]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const moduleId = (btn as HTMLElement).dataset.catalogToggle!;
const wasEnabled = (btn as HTMLElement).dataset.catalogEnabled === "true";
await this.#toggleModule(moduleId, !wasEnabled);
});
});
}
async #toggleModule(moduleId: string, enable: boolean) {
const space = this.#getSpaceSlug();
if (!space || space === "demo") {
// Demo mode: toggle locally
this.#toggleModuleLocal(moduleId, enable);
return;
}
// Get auth token
let token: string | null = null;
try {
const session = JSON.parse(localStorage.getItem("encryptid_session") || "");
token = session?.accessToken;
} catch {}
if (!token) {
alert("Sign in to manage rApps for this space.");
return;
}
this.#catalogBusy = true;
this.#render();
try {
// Build new enabled list
const currentEnabled = this.#allModules
.filter(m => m.enabled !== false)
.map(m => m.id);
let newEnabled: string[];
if (enable) {
newEnabled = [...currentEnabled, moduleId];
} else {
newEnabled = currentEnabled.filter(id => id !== moduleId && id !== "rspace");
}
// Always include rspace
if (!newEnabled.includes("rspace")) newEnabled.push("rspace");
const res = await fetch(`/api/spaces/${encodeURIComponent(space)}/modules`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ enabledModules: newEnabled }),
});
if (res.ok) {
// Update local state
this.#toggleModuleLocal(moduleId, enable);
// Dispatch event for shell to know
this.dispatchEvent(new CustomEvent("modules-changed", {
detail: { moduleId, enabled: enable, enabledModules: newEnabled },
bubbles: true, composed: true,
}));
} else {
const err = await res.json().catch(() => ({}));
alert((err as any).error || "Failed to update modules");
}
} catch (err) {
console.error("[AppSwitcher] Toggle failed:", err);
alert("Failed to update modules. Check your connection.");
}
this.#catalogBusy = false;
this.#render();
}
#toggleModuleLocal(moduleId: string, enable: boolean) {
// Update allModules
const mod = this.#allModules.find(m => m.id === moduleId);
if (mod) mod.enabled = enable;
// Update visible modules list
if (enable) {
if (!this.#modules.find(m => m.id === moduleId) && mod) {
this.#modules.push(mod);
}
} else {
this.#modules = this.#modules.filter(m => m.id !== moduleId);
}
this.#render();
} }
#getSpaceSlug(): string { #getSpaceSlug(): string {
@ -511,6 +680,60 @@ a.rstack-header:hover { background: var(--rs-bg-hover); }
border-top: none; margin-top: 0; padding-top: 8px; border-top: none; margin-top: 0; padding-top: 8px;
} }
/* ── Catalog / Manage rApps ── */
.catalog-divider {
border-top: 1px solid var(--rs-border-subtle);
margin-top: 4px;
}
.catalog-toggle {
display: flex; align-items: center; gap: 6px;
width: 100%; padding: 10px 14px;
background: none; border: none; cursor: pointer;
font-size: 0.75rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--rs-accent, #6366f1);
transition: background 0.12s;
}
.catalog-toggle:hover { background: var(--rs-bg-hover); }
.catalog-count {
font-size: 0.6rem; font-weight: 600;
padding: 1px 6px; border-radius: 8px;
background: rgba(99,102,241,0.15); color: var(--rs-accent, #6366f1);
text-transform: none; letter-spacing: 0;
}
.catalog-panel {
padding-bottom: 8px;
}
.catalog-section-label {
padding: 6px 14px 2px; font-size: 0.6rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.4;
user-select: none;
}
.catalog-item {
display: flex; align-items: center; gap: 10px;
padding: 6px 14px; transition: background 0.12s;
}
.catalog-item:hover { background: var(--rs-bg-hover); }
.catalog-item--disabled { opacity: 0.6; }
.catalog-btn {
width: 26px; height: 26px; border-radius: 6px;
border: 1px solid var(--rs-border); background: var(--rs-bg-surface);
color: var(--rs-text-primary); font-size: 1rem; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.12s, border-color 0.12s;
flex-shrink: 0;
}
.catalog-btn:hover { background: var(--rs-bg-hover); }
.catalog-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.catalog-btn--add {
border-color: rgba(99,102,241,0.4); color: var(--rs-accent, #6366f1);
}
.catalog-btn--add:hover { border-color: var(--rs-accent, #6366f1); background: rgba(99,102,241,0.1); }
.catalog-btn--remove {
border-color: rgba(239,68,68,0.3); color: #ef4444;
}
.catalog-btn--remove:hover { border-color: #ef4444; background: rgba(239,68,68,0.1); }
/* Mobile: sidebar overlays instead of pushing */ /* Mobile: sidebar overlays instead of pushing */
@media (max-width: 640px) { @media (max-width: 640px) {
.sidebar { box-shadow: 4px 0 20px rgba(0,0,0,0.3); } .sidebar { box-shadow: 4px 0 20px rgba(0,0,0,0.3); }