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

455 lines
14 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[] = [];
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
static get observedAttributes() {
return ["current"];
}
get current(): string {
return this.getAttribute("current") || "";
}
connectedCallback() {
this.#render();
}
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")!;
const open = () => {
sidebar.classList.add("open");
document.body.classList.add("rstack-sidebar-open");
};
const close = () => {
sidebar.classList.remove("open");
document.body.classList.remove("rstack-sidebar-open");
};
const toggle = () => {
if (sidebar.classList.contains("open")) close(); else open();
};
trigger.addEventListener("click", (e) => {
e.stopPropagation();
toggle();
});
collapse.addEventListener("click", () => close());
// Intercept same-origin module links → dispatch event for tab system
this.#shadow.querySelectorAll("a.item").forEach((el) => {
el.addEventListener("click", (e) => {
const moduleId = (el as HTMLElement).dataset.id;
if (!moduleId) return;
// Only intercept same-origin links (skip bare-domain landing pages)
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();
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); }
}
`;