From 34877b1f9ed790f1c3cea21a120e8f3e8979033f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 22:26:31 -0800 Subject: [PATCH] feat: add "My Spaces" modal to identity dropdown Adds a clickable "My Spaces" item in the profile dropdown that opens a full-screen overlay showing all user spaces as clickable cards. Fetches /api/spaces, splits into "Your Spaces" and "Public Spaces" sections, with navigation via rspaceNavUrl and a "Create New Space" CTA. Co-Authored-By: Claude Opus 4.6 --- shared/components/rstack-identity.ts | 186 +++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index e1a81ff..467f627 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -6,6 +6,8 @@ * Refactored from lib/rspace-header.ts into a standalone web component. */ +import { rspaceNavUrl, getCurrentModule } from "../url-helpers"; + const SESSION_KEY = "encryptid_session"; const ENCRYPTID_URL = "https://auth.rspace.online"; @@ -270,6 +272,8 @@ export class RStackIdentity extends HTMLElement { ${notifsHTML} + + @@ -337,6 +341,8 @@ export class RStackIdentity extends HTMLElement { this.#showAddDeviceModal(); } else if (action === "add-recovery") { this.#showAddRecoveryModal(); + } else if (action === "my-spaces") { + this.#showSpacesModal(); } }); }); @@ -876,6 +882,118 @@ export class RStackIdentity extends HTMLElement { loadGuardians(); } + // ── Spaces modal ── + + #showSpacesModal(): void { + if (document.querySelector(".rstack-spaces-overlay")) return; + const overlay = document.createElement("div"); + overlay.className = "rstack-spaces-overlay"; + + const close = () => overlay.remove(); + + const renderLoading = () => { + overlay.innerHTML = ` + +
+ +

My Spaces

+

Loading your spaces...

+
+
+ `; + overlay.querySelector('[data-action="close"]')?.addEventListener("click", close); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + }; + + const visIcon = (v: string) => + v === "members_only" ? "🔒" : v === "authenticated" ? "🔑" : "🔓"; + + const renderSpaces = (spaces: any[]) => { + const yourSpaces = spaces.filter((s) => s.role); + const publicSpaces = spaces.filter((s) => !s.role && s.accessible); + + const cardHTML = (s: any) => ` + + `; + + const yourSection = yourSpaces.length + ? ` +
${yourSpaces.map(cardHTML).join("")}
` + : ""; + + const publicSection = publicSpaces.length + ? ` +
${publicSpaces.map(cardHTML).join("")}
` + : ""; + + const emptyState = !yourSpaces.length && !publicSpaces.length + ? `

No spaces yet. Create one to get started!

` + : ""; + + overlay.innerHTML = ` + +
+ +

My Spaces

+ ${emptyState} + ${yourSection} + ${publicSection} +
+ +
+
+ `; + + overlay.querySelector('[data-action="close"]')?.addEventListener("click", close); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + + overlay.querySelectorAll("[data-slug]").forEach((el) => { + el.addEventListener("click", () => { + const slug = (el as HTMLElement).dataset.slug!; + close(); + window.location.href = rspaceNavUrl(slug, getCurrentModule()); + }); + }); + + overlay.querySelector('[data-action="create-space"]')?.addEventListener("click", () => { + close(); + window.location.href = "/create-space"; + }); + }; + + document.body.appendChild(overlay); + renderLoading(); + + const token = getAccessToken(); + fetch("/api/spaces", { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + .then((r) => r.json()) + .then((data) => renderSpaces(data.spaces || [])) + .catch(() => { + overlay.innerHTML = ` + +
+ +

My Spaces

+

Failed to load spaces. Please try again.

+
+ `; + overlay.querySelector('[data-action="close"]')?.addEventListener("click", close); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + }); + } + static define(tag = "rstack-identity") { if (!customElements.get(tag)) customElements.define(tag, RStackIdentity); } @@ -1097,3 +1215,71 @@ const SETTINGS_STYLES = ` } .threshold-hint { color: #64748b; font-size: 0.8rem; } `; + +const SPACES_STYLES = ` +.rstack-spaces-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); display: flex; align-items: center; + justify-content: center; z-index: 10000; animation: fadeIn 0.2s; +} +.spaces-modal { + background: #1e293b; border: 1px solid rgba(255,255,255,0.1); + border-radius: 16px; padding: 2rem; max-width: 720px; width: 92%; + max-height: 85vh; overflow-y: auto; color: white; position: relative; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); animation: slideUp 0.3s; +} +.spaces-modal h2 { + font-size: 1.5rem; margin-bottom: 0.5rem; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.spaces-section-label { + font-size: 0.75rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.05em; color: #64748b; margin: 1rem 0 0.5rem; +} +.spaces-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} +.space-card { + display: flex; flex-direction: column; align-items: center; gap: 8px; + padding: 20px 12px; border-radius: 12px; cursor: pointer; + background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); + transition: all 0.2s; text-align: center; color: white; font-family: inherit; + font-size: inherit; +} +.space-card:hover { + background: rgba(255,255,255,0.08); border-color: rgba(6,182,212,0.4); + transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.2); +} +.space-card-initial { + width: 48px; height: 48px; border-radius: 50%; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + display: flex; align-items: center; justify-content: center; + font-size: 1.3rem; font-weight: 700; color: white; +} +.space-card-name { + font-size: 0.9rem; font-weight: 600; max-width: 100%; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.space-card-meta { + display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; +} +.space-vis { + font-size: 0.7rem; color: #94a3b8; background: rgba(255,255,255,0.06); + padding: 2px 8px; border-radius: 10px; +} +.space-role { + font-size: 0.7rem; color: #06b6d4; background: rgba(6,182,212,0.1); + padding: 2px 8px; border-radius: 10px; font-weight: 600; +} +.space-card--create { + border-style: dashed; border-color: rgba(255,255,255,0.15); +} +.space-card--create .space-card-initial { + background: rgba(255,255,255,0.08); font-size: 1.5rem; +} +.space-card--create:hover .space-card-initial { + background: linear-gradient(135deg, #06b6d4, #7c3aed); +} +`;