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

886 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
enabled?: boolean;
}
// Pastel badge abbreviations & colors for each module
const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
// Create
rspace: { badge: "r🎨", color: "#5eead4" }, // teal-300
rdocs: { badge: "r📄", color: "#a5b4fc" }, // indigo-300
rnotes: { badge: "r📝", color: "#fcd34d" }, // amber-300
rpubs: { badge: "r📖", color: "#fda4af" }, // rose-300
rdesign: { badge: "r🎨", color: "#c4b5fd" }, // violet-300
rsheets: { badge: "r📑", color: "#86efac" }, // green-300
rswag: { badge: "r👕", color: "#fda4af" }, // rose-300
rsplat: { badge: "r🔮", color: "#d8b4fe" }, // purple-300
// Communicate
rchats: { badge: "r🗨", color: "#6ee7b7" }, // emerald-200
rinbox: { badge: "r📨", color: "#a5b4fc" }, // indigo-300
rmail: { badge: "r✉", color: "#93c5fd" }, // blue-200
rforum: { badge: "r💬", color: "#fcd34d" }, // amber-200
rmeets: { badge: "r📹", color: "#67e8f9" }, // cyan-300
// Coordinate
rcal: { badge: "r📅", color: "#7dd3fc" }, // sky-300
rchoices: { badge: "r☑", color: "#f0abfc" }, // fuchsia-300
rminders: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
rtime: { badge: "r⏳", color: "#a78bfa" }, // violet-400
rvote: { badge: "r🗳", color: "#c4b5fd" }, // violet-300
crowdsurf: { badge: "r🏄", color: "#fde68a" }, // amber-200
// Connect
rnetwork: { badge: "r🌐", color: "#93c5fd" }, // blue-300
rsocials: { badge: "r📢", color: "#7dd3fc" }, // sky-200
// Commerce
rexchange: { badge: "r💱", color: "#fde047" }, // yellow-300
rflows: { badge: "r🌊", color: "#bef264" }, // lime-300
rwallet: { badge: "r💰", color: "#fde047" }, // yellow-300
rcart: { badge: "r🛒", color: "#fdba74" }, // orange-300
rauctions: { badge: "r🎭", color: "#fca5a5" }, // red-300
// Govern
rgov: { badge: "r⚖", color: "#94a3b8" }, // slate-400
rcred: { badge: "r⭐", color: "#d97706" }, // amber-600
// Media
rphotos: { badge: "r📸", color: "#f9a8d4" }, // pink-200
rfiles: { badge: "r📁", color: "#67e8f9" }, // cyan-300
rtube: { badge: "r🎬", color: "#f9a8d4" }, // pink-300
rbooks: { badge: "r📚", color: "#fda4af" }, // rose-300
// Travel & Stay
rmaps: { badge: "r🗺", color: "#86efac" }, // green-300
rtrips: { badge: "r✈", color: "#6ee7b7" }, // emerald-300
rbnb: { badge: "r🏠", color: "#fbbf24" }, // amber-300
rvnb: { badge: "r🚐", color: "#a5f3fc" }, // cyan-200
// Observe
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300
// Platform
ragents: { badge: "r🤖", color: "#6ee7b7" }, // emerald-300
rids: { badge: "r🪪", 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> = {
// Create
rdocs: "Create",
rdesign: "Create",
rnotes: "Create",
rpubs: "Create",
rsheets: "Create",
rsplat: "Create",
rspace: "Create",
rswag: "Create",
// Communicate
rchats: "Communicate",
rforum: "Communicate",
rinbox: "Communicate",
rmail: "Communicate",
rmeets: "Communicate",
// Coordinate
crowdsurf: "Coordinate",
rcal: "Coordinate",
rchoices: "Coordinate",
rminders: "Coordinate",
rtasks: "Coordinate",
rtime: "Coordinate",
rvote: "Coordinate",
// Connect
rnetwork: "Connect",
rsocials: "Connect",
// Commerce
rauctions: "Commerce",
rcart: "Commerce",
rexchange: "Commerce",
rflows: "Commerce",
rwallet: "Commerce",
// Govern
rgov: "Govern",
// Media
rbooks: "Media",
rfiles: "Media",
rphotos: "Media",
rtube: "Media",
// Travel & Stay
rbnb: "Travel & Stay",
rmaps: "Travel & Stay",
rtrips: "Travel & Stay",
rvnb: "Travel & Stay",
// Observe
rdata: "Observe",
// Platform
ragents: "Platform",
rids: "Platform",
rstack: "Platform",
};
/** Color the "r" prefix orange in rApp names. */
function brandR(name: string): string {
if (name.startsWith("r") && name.length > 1 && name[1] === name[1].toUpperCase()) {
return `<span style="color:#dc8300">r</span>${name.slice(1)}`;
}
return name;
}
const CATEGORY_ORDER = [
"Create",
"Communicate",
"Coordinate",
"Connect",
"Commerce",
"Govern",
"Media",
"Travel & Stay",
"Observe",
"Platform",
];
import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helpers";
export class RStackAppSwitcher extends HTMLElement {
#shadow: ShadowRoot;
#modules: AppSwitcherModule[] = [];
#allModules: AppSwitcherModule[] = []; // Full catalog including disabled
#isOpen = false;
#catalogOpen = false;
#catalogBusy = false;
#sortMode: 'function' | 'alpha' = 'function';
#pinnedIds: Set<string> = new Set();
#outsideClickHandler: ((e: PointerEvent) => void) | null = null;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
// Restore persisted preferences
try {
const sort = localStorage.getItem("rspace-sort-mode");
if (sort === "alpha" || sort === "function") this.#sortMode = sort;
} catch {}
try {
const pinned = JSON.parse(localStorage.getItem("rspace-pinned-modules") || "[]");
if (Array.isArray(pinned)) this.#pinnedIds = new Set(pinned);
} catch {}
}
static get observedAttributes() {
return ["current"];
}
get current(): string {
return this.getAttribute("current") || "";
}
static #getRecentModules(): string[] {
try {
const raw = localStorage.getItem("rspace-recent-modules");
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch { return []; }
}
static #recordRecentModule(id: string) {
try {
const recent = RStackAppSwitcher.#getRecentModules().filter((x) => x !== id);
recent.unshift(id);
localStorage.setItem("rspace-recent-modules", JSON.stringify(recent.slice(0, 5)));
} catch {}
}
connectedCallback() {
if (this.current) RStackAppSwitcher.#recordRecentModule(this.current);
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("pointerdown", this.#outsideClickHandler);
this.#outsideClickHandler = null;
}
}
attributeChangedCallback() {
this.#render();
}
setModules(modules: AppSwitcherModule[]) {
this.#modules = modules;
this.#render();
}
/** Provide the full module catalog (enabled + disabled) for the "Manage rApps" panel */
setAllModules(modules: AppSwitcherModule[]) {
this.#allModules = modules;
}
#renderGroupedModules(current: string): string {
const alpha = (a: AppSwitcherModule, b: AppSwitcherModule) =>
a.name.localeCompare(b.name);
// 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>
`;
// Pinned section
const pinnedModules = this.#modules
.filter((m) => this.#pinnedIds.has(m.id))
.sort(alpha);
if (pinnedModules.length > 0) {
html += `<div class="category-header recent-header">Pinned</div>`;
html += pinnedModules.map((m) => this.#renderItem(m, current)).join("");
}
// Recently Used section
const recentIds = RStackAppSwitcher.#getRecentModules().filter((id) => id !== current);
const recentModules = recentIds
.map((id) => this.#modules.find((m) => m.id === id))
.filter((m): m is AppSwitcherModule => !!m);
if (recentModules.length > 0) {
html += `<div class="category-header recent-header">Recent</div>`;
html += recentModules.map((m) => this.#renderItem(m, current)).join("");
}
if (this.#sortMode === 'alpha') {
// Flat alphabetical list
const sorted = [...this.#modules].sort(alpha);
html += `<div class="category-header">All rApps</div>`;
html += sorted.map((m) => this.#renderItem(m, current)).join("");
} else {
// Group by category, alphabetical within each group
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);
}
}
for (const cat of CATEGORY_ORDER) {
const items = groups.get(cat);
if (!items || items.length === 0) continue;
items.sort(alpha);
html += `<div class="category-header">${cat}</div>`;
html += items.map((m) => this.#renderItem(m, current)).join("");
}
if (uncategorized.length > 0) {
uncategorized.sort(alpha);
html += `<div class="category-header">Other</div>`;
html += uncategorized.map((m) => this.#renderItem(m, current)).join("");
}
}
// Module enable/disable is managed via Edit Space → Modules tab, not from this dropdown.
// Sort toggle + Footer
html += `
<div class="sort-toggle">
<button class="sort-btn ${this.#sortMode === 'function' ? 'sort-active' : ''}" id="sort-function" title="Sort by function">By Function</button>
<button class="sort-btn ${this.#sortMode === 'alpha' ? 'sort-active' : ''}" id="sort-alpha" title="Sort alphabetically">AZ</button>
</div>
<div class="rstack-footer">
<a href="https://rstack.online" target="_blank" rel="noopener">rstack.online — self-hosted, community-run</a>
</div>
`;
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'}"><span style="color:#dc8300">r</span>${badgeInfo.badge.slice(1)}</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
? `<span class="item-badge" style="background:${badgeInfo.color}"><span style="color:#dc8300">r</span>${badgeInfo.badge.slice(1)}</span>`
: `<span class="item-icon">${m.icon}</span>`;
const space = this.#getSpaceSlug();
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>`
: "";
const isPinned = this.#pinnedIds.has(m.id);
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>
<span class="item-desc">${m.description}</span>
</div>
</a>
<button class="pin-btn ${isPinned ? 'pin-active' : ''}"
data-pin-id="${m.id}"
title="${isPinned ? 'Unpin' : 'Pin to top'}">★</button>
</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}"><span style="color:#dc8300">r</span>${badgeInfo.badge.slice(1)}</span> ${currentMod!.name}`
: currentMod
? `${currentMod.icon} ${currentMod.name}`
: `<span class="trigger-badge rstack-gradient"><span style="color:#dc8300">r</span>✨</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-backdrop" id="backdrop"></div>
<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 backdrop = this.#shadow.getElementById("backdrop")!;
// Sync sidebar DOM with tracked state (survives re-renders)
if (this.#isOpen) {
sidebar.classList.add("open");
backdrop.classList.add("visible");
document.body.classList.add("rstack-sidebar-open");
} else {
document.body.classList.remove("rstack-sidebar-open");
}
const open = () => {
this.#isOpen = true;
sidebar.classList.add("open");
backdrop.classList.add("visible");
document.body.classList.add("rstack-sidebar-open");
};
const close = () => {
this.#isOpen = false;
sidebar.classList.remove("open");
backdrop.classList.remove("visible");
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());
backdrop.addEventListener("click", () => close());
// Close sidebar when clicking outside (on main content)
if (this.#outsideClickHandler) {
document.removeEventListener("pointerdown", this.#outsideClickHandler);
}
this.#outsideClickHandler = (e: PointerEvent) => {
if (!this.#isOpen) return;
const path = e.composedPath();
if (!path.includes(sidebar) && !path.includes(trigger)) {
close();
}
};
document.addEventListener("pointerdown", this.#outsideClickHandler);
// Prefetch module fragments on hover for faster tab switching.
// Uses low-priority fetch so it doesn't compete with user-initiated requests.
if ((window as any).__rspaceTabBar) {
this.#shadow.querySelectorAll("a.item").forEach((el) => {
el.addEventListener("mouseenter", () => {
const moduleId = (el as HTMLElement).dataset.id;
if (!moduleId || moduleId === current) return;
const space = this.#getSpaceSlug();
const href = `/${space}/${moduleId}?fragment=1`;
fetch(href, { priority: "low" } as RequestInit).catch(() => {});
}, { once: true });
});
}
// 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;
RStackAppSwitcher.#recordRecentModule(moduleId);
// 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,
}));
});
});
// Sort toggle
this.#shadow.getElementById("sort-function")?.addEventListener("click", () => {
this.#sortMode = 'function';
try { localStorage.setItem("rspace-sort-mode", "function"); } catch {}
this.#render();
});
this.#shadow.getElementById("sort-alpha")?.addEventListener("click", () => {
this.#sortMode = 'alpha';
try { localStorage.setItem("rspace-sort-mode", "alpha"); } catch {}
this.#render();
});
// Pin/unpin buttons
this.#shadow.querySelectorAll<HTMLElement>("[data-pin-id]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
const id = btn.dataset.pinId!;
if (this.#pinnedIds.has(id)) {
this.#pinnedIds.delete(id);
} else {
this.#pinnedIds.add(id);
}
try { localStorage.setItem("rspace-pinned-modules", JSON.stringify([...this.#pinnedIds])); } catch {}
this.#render();
});
});
// 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();
}
/** Open the sidebar programmatically (used by tab bar [+] button) */
open() {
this.#isOpen = true;
this.#render();
}
#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, .trigger:active { 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.65rem; font-weight: 900; color: #1e293b;
line-height: 1; flex-shrink: 0; white-space: nowrap;
}
.trigger-badge.rstack-gradient {
background: linear-gradient(135deg, #67e8f9, #c4b5fd, #fda4af);
}
.caret { font-size: 0.7em; opacity: 0.6; }
/* ── Sidebar backdrop (mobile) ── */
.sidebar-backdrop {
display: none; position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.25s ease;
-webkit-tap-highlight-color: transparent;
}
@media (max-width: 640px) {
.sidebar-backdrop.visible { display: block; opacity: 1; }
}
/* ── Sidebar panel ── */
.sidebar {
position: fixed;
top: 56px; left: 0; bottom: 0;
width: 280px;
overflow-y: auto;
touch-action: pan-y;
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, .collapse-btn:active {
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, a.rstack-header:active { 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: #1e293b; 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, .item-row:active { 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-badge {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px;
font-size: 0.7rem; font-weight: 900; color: #1e293b;
line-height: 1; flex-shrink: 0; white-space: nowrap;
}
.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-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;
}
.recent-header {
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); }
/* ── Pin star button ── */
.pin-btn {
width: 28px; height: 28px; flex-shrink: 0;
border: none; background: none; cursor: pointer;
font-size: 0.85rem; line-height: 1;
color: var(--rs-text-secondary); opacity: 0;
transition: opacity 0.15s, color 0.15s;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
}
.item-row:hover .pin-btn,
.pin-btn.pin-active { opacity: 1; }
.pin-btn.pin-active { color: #fbbf24; }
.pin-btn:hover { color: #fbbf24; background: rgba(251,191,36,0.1); }
/* ── Sort toggle ── */
.sort-toggle {
display: flex; gap: 4px; padding: 6px 14px;
border-top: 1px solid var(--rs-border-subtle);
}
.sort-btn {
flex: 1; padding: 4px 8px; border-radius: 5px;
border: 1px solid var(--rs-border); background: none;
font-size: 0.65rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
color: var(--rs-text-secondary); cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.sort-btn:hover { background: var(--rs-bg-hover); }
.sort-btn.sort-active {
background: var(--rs-bg-active); color: var(--rs-text-primary);
border-color: var(--rs-accent, #6366f1);
}
/* Mobile: sidebar overlays instead of pushing */
@media (max-width: 640px) {
.sidebar { box-shadow: 4px 0 20px rgba(0,0,0,0.3); }
}
`;