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.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
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue