feat(app-switcher): add badges for all rApps, sort toggle, and pin favorites

- Add r<emoji> badges for rDocs, rDesign, rSheets, rTime, rGov, rAgents,
  rExchange to both MODULE_BADGES and FAVICON_BADGE_MAP
- Add MODULE_CATEGORIES entries for all new modules
- Add "Govern" category for rGov
- Sort modules alphabetically within each function category
- Add sort toggle (By Function / A-Z) at bottom of sidebar, persisted
  in localStorage
- Add star/pin button on each rApp — pinned items appear in a "Pinned"
  section above "Recent", persisted in localStorage
- Fix rAuctions module ID: 'auctions' → 'rauctions' for consistency,
  with alias in MODULE_ALIASES for backward compat
- Change rAuctions emoji from 🏛 to 🎭

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-13 08:49:52 -04:00
parent 75fd5cf4be
commit 48c7f15de4
4 changed files with 203 additions and 81 deletions

View File

@ -11,9 +11,9 @@ const routes = new Hono();
routes.get('/', (c) => c.text('rAuctions — coming soon'));
export const auctionsModule: RSpaceModule = {
id: 'auctions',
id: 'rauctions',
name: 'rAuctions',
icon: '🏛',
icon: '🎭',
description: 'Community auctions with USDC',
routes,
scoping: { defaultScope: 'space', userConfigurable: true },

View File

@ -3787,7 +3787,7 @@ async function serveStatic(path: string, url?: URL): Promise<Response | null> {
}
// ── Module ID aliases (plural/misspelling → canonical) ──
const MODULE_ALIASES: Record<string, string> = { rsheet: "rsheets" };
const MODULE_ALIASES: Record<string, string> = { rsheet: "rsheets", auctions: "rauctions" };
function resolveModuleAlias(id: string): string { return MODULE_ALIASES[id] ?? id; }
// ── Standalone domain → module lookup ──

View File

@ -16,40 +16,45 @@ const COMPAT_POLYFILLS = `<script>(function(){if(typeof structuredClone!=="funct
// ── Dynamic per-module favicon (inline, runs after body parse) ──
// Badge map mirrors MODULE_BADGES from rstack-app-switcher.ts — kept in sync manually.
const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
rspace: { badge: "r🎨", color: "#5eead4" },
rnotes: { badge: "r📝", color: "#fcd34d" },
rpubs: { badge: "r📖", color: "#fda4af" },
rswag: { badge: "r👕", color: "#fda4af" },
rsplat: { badge: "r🔮", color: "#d8b4fe" },
rcal: { badge: "r📅", color: "#7dd3fc" },
rtrips: { badge: "r✈", color: "#6ee7b7" },
rmaps: { badge: "r🗺", color: "#86efac" },
rchats: { badge: "r🗨", color: "#6ee7b7" },
rinbox: { badge: "r📨", color: "#a5b4fc" },
rmail: { badge: "r✉", color: "#93c5fd" },
rforum: { badge: "r💬", color: "#fcd34d" },
rmeets: { badge: "r📹", color: "#67e8f9" },
rspace: { badge: "r🎨", color: "#5eead4" },
rdocs: { badge: "r📄", color: "#a5b4fc" },
rdesign: { badge: "r🎨", color: "#c4b5fd" },
rnotes: { badge: "r📝", color: "#fcd34d" },
rpubs: { badge: "r📖", color: "#fda4af" },
rsheets: { badge: "r📑", color: "#86efac" },
rsplat: { badge: "r🔮", color: "#d8b4fe" },
rswag: { badge: "r👕", color: "#fda4af" },
rchats: { badge: "r🗨", color: "#6ee7b7" },
rforum: { badge: "r💬", color: "#fcd34d" },
rinbox: { badge: "r📨", color: "#a5b4fc" },
rmail: { badge: "r✉", color: "#93c5fd" },
rmeets: { badge: "r📹", color: "#67e8f9" },
rcal: { badge: "r📅", color: "#7dd3fc" },
rchoices: { badge: "r☑", color: "#f0abfc" },
rschedule: { badge: "r⏱", color: "#a5b4fc" },
rtasks: { badge: "r📋", color: "#cbd5e1" },
rtime: { badge: "r⏳", color: "#a78bfa" },
rvote: { badge: "r🗳", color: "#c4b5fd" },
crowdsurf: { badge: "r🏄", color: "#fde68a" },
rnetwork: { badge: "r🌐", color: "#93c5fd" },
rsocials: { badge: "r📢", color: "#7dd3fc" },
rexchange: { badge: "r💱", color: "#fde047" },
rflows: { badge: "r🌊", color: "#bef264" },
rwallet: { badge: "r💰", color: "#fde047" },
rcart: { badge: "r🛒", color: "#fdba74" },
rauctions: { badge: "r🏛", color: "#fca5a5" },
rtube: { badge: "r🎬", color: "#f9a8d4" },
rauctions: { badge: "r🎭", color: "#fca5a5" },
rgov: { badge: "r⚖", color: "#94a3b8" },
rphotos: { badge: "r📸", color: "#f9a8d4" },
rnetwork: { badge: "r🌐", color: "#93c5fd" },
rsocials: { badge: "r📢", color: "#7dd3fc" },
rfiles: { badge: "r📁", color: "#67e8f9" },
rtube: { badge: "r🎬", color: "#f9a8d4" },
rbooks: { badge: "r📚", color: "#fda4af" },
rdata: { badge: "r📊", color: "#d8b4fe" },
rmaps: { badge: "r🗺", color: "#86efac" },
rtrips: { badge: "r✈", color: "#6ee7b7" },
rbnb: { badge: "r🏠", color: "#fbbf24" },
rvnb: { badge: "r🚐", color: "#a5f3fc" },
rtasks: { badge: "r📋", color: "#cbd5e1" },
rschedule: { badge: "r⏱", color: "#a5b4fc" },
crowdsurf: { badge: "r🏄", color: "#fde68a" },
rdata: { badge: "r📊", color: "#d8b4fe" },
ragents: { badge: "r🤖", color: "#6ee7b7" },
rids: { badge: "r🪪", color: "#6ee7b7" },
rdesign: { badge: "r🎨", color: "#7c3aed" },
rtime: { badge: "r⏳", color: "#a78bfa" },
rstack: { badge: "r✨", color: "#c4b5fd" },
};

View File

@ -20,47 +20,54 @@ export interface AppSwitcherModule {
// Pastel badge abbreviations & colors for each module
const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
// Creating
// 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
// Planning
rcal: { badge: "r📅", color: "#7dd3fc" }, // sky-300
rtrips: { badge: "r✈", color: "#6ee7b7" }, // emerald-300
rmaps: { badge: "r🗺", color: "#86efac" }, // green-300
// Communicating
// 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
// Deciding
// Coordinate
rcal: { badge: "r📅", color: "#7dd3fc" }, // sky-300
rchoices: { badge: "r☑", color: "#f0abfc" }, // fuchsia-300
rschedule: { 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
// Funding & Commerce
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
rtube: { badge: "r🎬", color: "#f9a8d4" }, // pink-300
// Sharing
rauctions: { badge: "r🎭", color: "#fca5a5" }, // red-300
// Govern
rgov: { badge: "r⚖", color: "#94a3b8" }, // slate-400
// Media
rphotos: { badge: "r📸", color: "#f9a8d4" }, // pink-200
rnetwork: { badge: "r🌐", color: "#93c5fd" }, // blue-300
rsocials: { badge: "r📢", color: "#7dd3fc" }, // sky-200
rfiles: { badge: "r📁", color: "#67e8f9" }, // cyan-300
rtube: { badge: "r🎬", color: "#f9a8d4" }, // pink-300
rbooks: { badge: "r📚", color: "#fda4af" }, // rose-300
// Observing
rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-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
// Coordinate
rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300
rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200
crowdsurf: { badge: "r🏄", color: "#fde68a" }, // amber-200
// Identity & Infrastructure
// 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)
};
@ -68,45 +75,53 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
// Category definitions for the rApp dropdown (display-only grouping)
const MODULE_CATEGORIES: Record<string, string> = {
// Create
rspace: "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",
rforum: "Communicate",
rmeets: "Communicate",
// Coordinate
crowdsurf: "Coordinate",
rcal: "Coordinate",
rchoices: "Coordinate",
rschedule: "Coordinate",
rtasks: "Coordinate",
rchoices: "Coordinate",
rtime: "Coordinate",
rvote: "Coordinate",
crowdsurf: "Coordinate",
// Connect
rnetwork: "Connect",
rsocials: "Connect",
// Commerce
rauctions: "Commerce",
rcart: "Commerce",
rexchange: "Commerce",
rflows: "Commerce",
rwallet: "Commerce",
rcart: "Commerce",
rauctions: "Commerce",
// Govern
rgov: "Govern",
// Media
rphotos: "Media",
rfiles: "Media",
rtube: "Media",
rbooks: "Media",
rfiles: "Media",
rphotos: "Media",
rtube: "Media",
// Travel & Stay
rbnb: "Travel & Stay",
rmaps: "Travel & Stay",
rtrips: "Travel & Stay",
rbnb: "Travel & Stay",
rvnb: "Travel & Stay",
// Observe
rdata: "Observe",
// Platform
ragents: "Platform",
rids: "Platform",
rstack: "Platform",
};
@ -117,6 +132,7 @@ const CATEGORY_ORDER = [
"Coordinate",
"Connect",
"Commerce",
"Govern",
"Media",
"Travel & Stay",
"Observe",
@ -132,11 +148,22 @@ export class RStackAppSwitcher extends HTMLElement {
#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() {
@ -193,19 +220,8 @@ export class RStackAppSwitcher extends HTMLElement {
}
#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);
}
}
const alpha = (a: AppSwitcherModule, b: AppSwitcherModule) =>
a.name.localeCompare(b.name);
// rStack header (clickable)
let html = `
@ -218,6 +234,15 @@ export class RStackAppSwitcher extends HTMLElement {
</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
@ -228,15 +253,38 @@ export class RStackAppSwitcher extends HTMLElement {
html += recentModules.map((m) => this.#renderItem(m, current)).join("");
}
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("");
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("");
}
}
// "Manage rApps" catalog section
@ -278,8 +326,12 @@ export class RStackAppSwitcher extends HTMLElement {
}
}
// Footer
// 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>
@ -319,8 +371,6 @@ export class RStackAppSwitcher extends HTMLElement {
: `<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 =
@ -332,6 +382,8 @@ export class RStackAppSwitcher extends HTMLElement {
? `<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"
@ -346,6 +398,9 @@ export class RStackAppSwitcher extends HTMLElement {
<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>
`;
}
@ -465,6 +520,34 @@ export class RStackAppSwitcher extends HTMLElement {
});
});
// 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;
@ -789,6 +872,40 @@ a.rstack-header:hover, a.rstack-header:active { background: var(--rs-bg-hover);
}
.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); }