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:
parent
c233f1338b
commit
668c239cf3
|
|
@ -102,6 +102,11 @@ export function renderShell(opts: ShellOptions): string {
|
|||
? modules.filter(m => m.id === "rspace" || enabledModules.includes(m.id))
|
||||
: modules;
|
||||
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)}`;
|
||||
|
||||
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){}})();
|
||||
// Provide module list to app switcher and offline runtime
|
||||
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 ──
|
||||
// Hidden when logged in. When logged out, shown everywhere except demo.rspace.online
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface AppSwitcherModule {
|
|||
description: string;
|
||||
standaloneDomain?: string;
|
||||
scoping?: { defaultScope: 'space' | 'global'; userConfigurable: boolean };
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Pastel badge abbreviations & colors for each module
|
||||
|
|
@ -112,7 +113,10 @@ import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helper
|
|||
export class RStackAppSwitcher extends HTMLElement {
|
||||
#shadow: ShadowRoot;
|
||||
#modules: AppSwitcherModule[] = [];
|
||||
#allModules: AppSwitcherModule[] = []; // Full catalog including disabled
|
||||
#isOpen = false;
|
||||
#catalogOpen = false;
|
||||
#catalogBusy = false;
|
||||
#outsideClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
|
|
@ -168,6 +172,11 @@ export class RStackAppSwitcher extends HTMLElement {
|
|||
this.#render();
|
||||
}
|
||||
|
||||
/** Provide the full module catalog (enabled + disabled) for the "Manage rApps" panel */
|
||||
setAllModules(modules: AppSwitcherModule[]) {
|
||||
this.#allModules = modules;
|
||||
}
|
||||
|
||||
#renderGroupedModules(current: string): string {
|
||||
// Group modules by category
|
||||
const groups = new Map<string, AppSwitcherModule[]>();
|
||||
|
|
@ -215,6 +224,42 @@ export class RStackAppSwitcher extends HTMLElement {
|
|||
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
|
||||
html += `
|
||||
<div class="rstack-footer">
|
||||
|
|
@ -225,6 +270,30 @@ export class RStackAppSwitcher extends HTMLElement {
|
|||
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 {
|
||||
const badgeInfo = MODULE_BADGES[m.id];
|
||||
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 {
|
||||
|
|
@ -511,6 +680,60 @@ a.rstack-header:hover { background: var(--rs-bg-hover); }
|
|||
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 */
|
||||
@media (max-width: 640px) {
|
||||
.sidebar { box-shadow: 4px 0 20px rgba(0,0,0,0.3); }
|
||||
|
|
|
|||
Loading…
Reference in New Issue