rspace-online/shared/components/rstack-app-switcher.ts

495 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <rstack-app-switcher> — Collapsible left sidebar to switch between rSpace modules.
*
* Attributes:
* current — the active module ID (highlighted)
*
* Methods:
* setModules(list) — provide the list of available modules
*/
export interface AppSwitcherModule {
id: string;
name: string;
icon: string;
description: string;
standaloneDomain?: string;
scoping?: { defaultScope: 'space' | 'global'; userConfigurable: boolean };
}
// Pastel badge abbreviations & colors for each module
const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
// Creating
rspace: { badge: "rS", color: "#5eead4" }, // teal-300
rnotes: { badge: "rN", color: "#fcd34d" }, // amber-300
rpubs: { badge: "rP", color: "#fda4af" }, // rose-300
rswag: { badge: "rSw", color: "#fda4af" }, // rose-300
rsplat: { badge: "r3", color: "#d8b4fe" }, // purple-300
// Planning
rcal: { badge: "rC", color: "#7dd3fc" }, // sky-300
rtrips: { badge: "rT", color: "#6ee7b7" }, // emerald-300
rmaps: { badge: "rM", color: "#86efac" }, // green-300
// Communicating
rchats: { badge: "rCh", color: "#6ee7b7" }, // emerald-200
rinbox: { badge: "rI", color: "#a5b4fc" }, // indigo-300
rmail: { badge: "rMa", color: "#93c5fd" }, // blue-200
rforum: { badge: "rFo", color: "#fcd34d" }, // amber-200
// Deciding
rchoices: { badge: "rCo", color: "#f0abfc" }, // fuchsia-300
rvote: { badge: "rV", color: "#c4b5fd" }, // violet-300
// Funding & Commerce
rflows: { badge: "rFl", color: "#bef264" }, // lime-300
rwallet: { badge: "rW", color: "#fde047" }, // yellow-300
rcart: { badge: "rCt", color: "#fdba74" }, // orange-300
rauctions: { badge: "rA", color: "#fca5a5" }, // red-300
rtube: { badge: "rTu", color: "#f9a8d4" }, // pink-300
// Sharing
rphotos: { badge: "rPh", color: "#f9a8d4" }, // pink-200
rnetwork: { badge: "rNe", color: "#93c5fd" }, // blue-300
rsocials: { badge: "rSo", color: "#7dd3fc" }, // sky-200
rfiles: { badge: "rFi", color: "#67e8f9" }, // cyan-300
rbooks: { badge: "rB", color: "#fda4af" }, // rose-300
// Observing
rdata: { badge: "rD", color: "#d8b4fe" }, // purple-300
// Work & Productivity
rwork: { badge: "rWo", color: "#cbd5e1" }, // slate-300
// Identity & Infrastructure
rids: { badge: "rId", color: "#6ee7b7" }, // emerald-300
rstack: { badge: "r*", color: "" }, // gradient (handled separately)
};
// Category definitions for the rApp dropdown (display-only grouping)
const MODULE_CATEGORIES: Record<string, string> = {
rspace: "Creating",
rnotes: "Creating",
rpubs: "Creating",
rtube: "Creating",
rswag: "Creating",
rsplat: "Creating",
rcal: "Planning",
rtrips: "Planning",
rmaps: "Planning",
rchats: "Communicating",
rinbox: "Communicating",
rmail: "Communicating",
rforum: "Communicating",
rchoices: "Deciding",
rvote: "Deciding",
rflows: "Funding & Commerce",
rwallet: "Funding & Commerce",
rcart: "Funding & Commerce",
rauctions: "Funding & Commerce",
rphotos: "Sharing",
rnetwork: "Sharing",
rsocials: "Sharing",
rfiles: "Sharing",
rbooks: "Sharing",
rdata: "Observing",
rwork: "Work & Productivity",
rids: "Identity & Infrastructure",
rstack: "Identity & Infrastructure",
};
const CATEGORY_ORDER = [
"Creating",
"Planning",
"Communicating",
"Deciding",
"Funding & Commerce",
"Sharing",
"Observing",
"Work & Productivity",
"Identity & Infrastructure",
];
import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helpers";
export class RStackAppSwitcher extends HTMLElement {
#shadow: ShadowRoot;
#modules: AppSwitcherModule[] = [];
#isOpen = false;
#outsideClickHandler: ((e: MouseEvent) => void) | null = null;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
static get observedAttributes() {
return ["current"];
}
get current(): string {
return this.getAttribute("current") || "";
}
connectedCallback() {
this.#render();
}
disconnectedCallback() {
// Clean up body class and outside-click listener on removal
document.body.classList.remove("rstack-sidebar-open");
if (this.#outsideClickHandler) {
document.removeEventListener("click", this.#outsideClickHandler);
this.#outsideClickHandler = null;
}
}
attributeChangedCallback() {
this.#render();
}
setModules(modules: AppSwitcherModule[]) {
this.#modules = modules;
this.#render();
}
#renderGroupedModules(current: string): string {
// Group modules by category
const groups = new Map<string, AppSwitcherModule[]>();
const uncategorized: AppSwitcherModule[] = [];
for (const m of this.#modules) {
const cat = MODULE_CATEGORIES[m.id];
if (cat) {
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(m);
} else {
uncategorized.push(m);
}
}
// rStack header (clickable)
let html = `
<a class="rstack-header" href="https://rstack.online" target="_blank" rel="noopener">
<span class="rstack-badge">r*</span>
<div class="rstack-info">
<span class="rstack-title">rStack</span>
<span class="rstack-subtitle">Self-hosted community app suite</span>
</div>
</a>
`;
for (const cat of CATEGORY_ORDER) {
const items = groups.get(cat);
if (!items || items.length === 0) continue;
html += `<div class="category-header">${cat}</div>`;
html += items.map((m) => this.#renderItem(m, current)).join("");
}
if (uncategorized.length > 0) {
html += `<div class="category-header">Other</div>`;
html += uncategorized.map((m) => this.#renderItem(m, current)).join("");
}
// Footer
html += `
<div class="rstack-footer">
<a href="https://rstack.online" target="_blank" rel="noopener">rstack.online — self-hosted, community-run</a>
</div>
`;
return html;
}
#renderItem(m: AppSwitcherModule, current: string): string {
const badgeInfo = MODULE_BADGES[m.id];
const badgeHtml = badgeInfo
? `<span class="item-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span>`
: `<span class="item-icon">${m.icon}</span>`;
const space = this.#getSpaceSlug();
// On bare domain or standalone r*.online: link to landing pages.
// On demo.rspace.online or user subdomains: use rspaceNavUrl for in-app navigation.
const host = window.location.host.split(":")[0];
const isBareDomain = host === "rspace.online" || host === "www.rspace.online";
const href =
(isBareDomain || isStandaloneDomain())
? `${window.location.protocol}//rspace.online/${m.id}`
: rspaceNavUrl(space, m.id);
const scopeBadge = m.scoping?.defaultScope === "global"
? `<span class="scope-badge scope-global" title="Global data (shared across spaces)">G</span>`
: "";
return `
<div class="item-row ${m.id === current ? "active" : ""}">
<a class="item"
href="${href}"
data-id="${m.id}">
${badgeHtml}
<div class="item-text">
<span class="item-name-row">
<span class="item-name">${m.name}</span>
${scopeBadge}
<span class="item-emoji">${m.icon}</span>
</span>
<span class="item-desc">${m.description}</span>
</div>
</a>
${m.standaloneDomain ? `<a class="item-ext" href="https://${m.standaloneDomain}" target="_blank" rel="noopener" title="${m.standaloneDomain}">↗</a>` : ""}
</div>
`;
}
#render() {
const current = this.current;
const currentMod = this.#modules.find((m) => m.id === current);
const badgeInfo = currentMod ? MODULE_BADGES[currentMod.id] : null;
const triggerContent = badgeInfo
? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${currentMod!.name}`
: currentMod
? `${currentMod.icon} ${currentMod.name}`
: `<span class="trigger-badge rstack-gradient">r*</span> rSpace`;
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="switcher">
<button class="trigger" id="trigger">${triggerContent} <span class="caret">▾</span></button>
<div class="sidebar" id="sidebar">
<button class="collapse-btn" id="collapse" title="Close sidebar"></button>
${this.#renderGroupedModules(current)}
</div>
</div>
`;
const trigger = this.#shadow.getElementById("trigger")!;
const sidebar = this.#shadow.getElementById("sidebar")!;
const collapse = this.#shadow.getElementById("collapse")!;
// Sync sidebar DOM with tracked state (survives re-renders)
if (this.#isOpen) {
sidebar.classList.add("open");
document.body.classList.add("rstack-sidebar-open");
} else {
document.body.classList.remove("rstack-sidebar-open");
}
const open = () => {
this.#isOpen = true;
sidebar.classList.add("open");
document.body.classList.add("rstack-sidebar-open");
};
const close = () => {
this.#isOpen = false;
sidebar.classList.remove("open");
document.body.classList.remove("rstack-sidebar-open");
};
const toggle = () => {
if (this.#isOpen) close(); else open();
};
trigger.addEventListener("click", (e) => {
e.stopPropagation();
toggle();
});
collapse.addEventListener("click", () => close());
// Close sidebar when clicking outside (on main content)
if (this.#outsideClickHandler) {
document.removeEventListener("click", this.#outsideClickHandler);
}
this.#outsideClickHandler = (e: MouseEvent) => {
if (!this.#isOpen) return;
const path = e.composedPath();
if (!path.includes(sidebar) && !path.includes(trigger)) {
close();
}
};
document.addEventListener("click", this.#outsideClickHandler);
// Intercept same-origin module links → dispatch event for tab system
// Only intercept when the shell tab system is active (window.__rspaceTabBar).
// On landing pages (rspace.online/, rspace.online/{moduleId}), let links
// navigate normally since there's no module-select listener.
this.#shadow.querySelectorAll("a.item").forEach((el) => {
el.addEventListener("click", (e) => {
const moduleId = (el as HTMLElement).dataset.id;
if (!moduleId) return;
// Skip interception if tab system isn't active (landing pages)
if (!(window as any).__rspaceTabBar) return;
// Only intercept same-origin links
const href = (el as HTMLAnchorElement).href;
try {
const url = new URL(href, window.location.href);
if (url.origin !== window.location.origin) return;
} catch { return; }
e.preventDefault();
close();
this.dispatchEvent(new CustomEvent("module-select", {
detail: { moduleId },
bubbles: true,
composed: true,
}));
});
});
}
#getSpaceSlug(): string {
// Read from the space switcher or URL
const spaceSwitcher = document.querySelector("rstack-space-switcher");
if (spaceSwitcher) return spaceSwitcher.getAttribute("current") || "demo";
return getCurrentSpace();
}
static define(tag = "rstack-app-switcher") {
if (!customElements.get(tag)) customElements.define(tag, RStackAppSwitcher);
}
}
const STYLES = `
:host { display: contents; }
.switcher { position: relative; }
.trigger {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 8px; border: none;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
transition: background 0.15s;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
white-space: nowrap; min-width: 0; flex-shrink: 1;
overflow: hidden; text-overflow: ellipsis;
}
.trigger:hover { background: var(--rs-bg-hover); }
.trigger-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 5px;
font-size: 0.6rem; font-weight: 900; color: var(--rs-text-inverse);
line-height: 1; flex-shrink: 0;
}
.trigger-badge.rstack-gradient {
background: linear-gradient(135deg, #67e8f9, #c4b5fd, #fda4af);
}
.caret { font-size: 0.7em; opacity: 0.6; }
/* ── Sidebar panel ── */
.sidebar {
position: fixed;
top: 56px; left: 0; bottom: 0;
width: 280px;
overflow-y: auto;
z-index: 10001;
background: var(--rs-bg-surface);
border-right: 1px solid var(--rs-border);
box-shadow: var(--rs-shadow-lg);
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open {
transform: translateX(0);
}
/* Collapse button */
.collapse-btn {
position: absolute;
top: 8px; right: 8px;
width: 28px; height: 28px;
border-radius: 6px;
border: 1px solid var(--rs-border);
background: var(--rs-bg-surface);
color: var(--rs-text-secondary);
font-size: 1rem; line-height: 1;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, color 0.15s;
z-index: 1;
}
.collapse-btn:hover {
background: var(--rs-bg-hover);
color: var(--rs-text-primary);
}
/* rStack header */
a.rstack-header {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-bottom: 1px solid var(--rs-border-subtle);
text-decoration: none; color: inherit; cursor: pointer;
transition: background 0.12s;
}
a.rstack-header:hover { background: var(--rs-bg-hover); }
.rstack-badge {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 8px;
background: linear-gradient(135deg, #67e8f9, #c4b5fd, #fda4af);
font-size: 0.7rem; font-weight: 900; color: var(--rs-text-inverse); line-height: 1;
flex-shrink: 0;
}
.rstack-info { display: flex; flex-direction: column; }
.rstack-title { font-size: 0.875rem; font-weight: 700; color: var(--rs-text-primary); }
.rstack-subtitle { font-size: 0.65rem; opacity: 0.5; }
/* Footer */
.rstack-footer {
padding: 10px 14px; text-align: center;
border-top: 1px solid var(--rs-border-subtle);
}
.rstack-footer a {
font-size: 0.7rem; opacity: 0.4; text-decoration: none; color: inherit;
transition: opacity 0.15s;
}
.rstack-footer a:hover { opacity: 0.8; color: var(--rs-accent); }
.item-row {
display: flex; align-items: center;
transition: background 0.12s;
color: var(--rs-text-primary);
}
.item-row:hover { background: var(--rs-bg-hover); }
.item-row.active { background: var(--rs-bg-active); }
.item {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; text-decoration: none;
cursor: pointer; flex: 1; min-width: 0; color: inherit;
}
.item-ext {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 100%; flex-shrink: 0;
font-size: 0.8rem; text-decoration: none; opacity: 0;
transition: opacity 0.15s;
color: var(--rs-accent);
}
.item-row:hover .item-ext { opacity: 0.5; }
.item-ext:hover { opacity: 1 !important; }
.item-badge {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px;
font-size: 0.6rem; font-weight: 900; color: var(--rs-text-inverse);
line-height: 1; flex-shrink: 0;
}
.item-icon { font-size: 1.3rem; width: 28px; text-align: center; flex-shrink: 0; }
.item-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.item-name-row { display: flex; align-items: center; gap: 6px; }
.item-name { font-size: 0.875rem; font-weight: 600; }
.item-emoji { font-size: 0.875rem; flex-shrink: 0; }
.item-desc { font-size: 0.7rem; opacity: 0.5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.scope-badge {
font-size: 0.55rem; font-weight: 700; padding: 1px 4px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.04em; line-height: 1; flex-shrink: 0;
}
.scope-global { background: rgba(139,92,246,0.2); color: #a78bfa; }
.category-header {
padding: 8px 14px 4px; font-size: 0.6rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.5;
user-select: none;
}
.category-header:not(:first-child) {
border-top: 1px solid var(--rs-border-subtle);
margin-top: 4px; padding-top: 10px;
}
/* Mobile: sidebar overlays instead of pushing */
@media (max-width: 640px) {
.sidebar { box-shadow: 4px 0 20px rgba(0,0,0,0.3); }
}
`;