diff --git a/server/spaces.ts b/server/spaces.ts index 1f170c2..0d2e8f4 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -226,15 +226,27 @@ spaces.get("/", async (c) => { } const spacesList = []; + const seenSlugs = new Set(); + const username = claims?.username?.toLowerCase(); + for (const slug of slugs) { await loadCommunity(slug); const data = getDocumentData(slug); if (data?.meta) { - const vis = data.meta.visibility || "public"; + // Deduplicate by slug + if (seenSlugs.has(slug)) continue; + seenSlugs.add(slug); + + let vis = data.meta.visibility || "public"; const isOwner = !!(claims && data.meta.ownerDID === claims.sub); const memberEntry = claims ? data.members?.[claims.sub] : undefined; const isMember = !!memberEntry; + // Owner's personal space (slug matches username) is always private + if (isOwner && username && slug === username) { + vis = "private"; + } + // Determine accessibility const isPublicSpace = vis === "public"; const isPermissioned = vis === "permissioned"; @@ -276,8 +288,13 @@ spaces.get("/", async (c) => { } } - // Sort: user's own spaces first, then demo, then others alphabetically + // Sort: private first (red), then permissioned (yellow), then public (green) + // Within each group: user's own spaces first, then alphabetically + const visOrder: Record = { private: 0, permissioned: 1, public: 2 }; spacesList.sort((a, b) => { + const va = visOrder[a.visibility || "public"] ?? 2; + const vb = visOrder[b.visibility || "public"] ?? 2; + if (va !== vb) return va - vb; if (a.role && !b.role) return -1; if (!a.role && b.role) return 1; if (a.slug === "demo") return -1; diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 0aced90..4c0d7e9 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -131,6 +131,23 @@ export class RStackSpaceSwitcher extends HTMLElement { return s.name; } + #renderSpaceGroup(spaces: SpaceInfo[], current: string, moduleId: string): string { + return spaces + .map((s) => { + const vis = this.#visibilityInfo(s); + const canEdit = s.role === "owner" || s.role === "admin"; + return ` + `; + }) + .join(""); + } + #demoSpaceHTML(current: string, moduleId: string): string { const isActive = current === "demo"; return ` @@ -171,10 +188,14 @@ export class RStackSpaceSwitcher extends HTMLElement { return; } - // Split: user's own spaces vs permissioned spaces they can discover + // Split spaces by visibility and role const mySpaces = this.#spaces.filter((s) => s.role); const discoverSpaces = this.#spaces.filter((s) => s.accessible === false); + const privateSpaces = mySpaces.filter((s) => s.visibility === "private"); + const permissionedSpaces = mySpaces.filter((s) => s.visibility === "permissioned"); + const publicSpaces = mySpaces.filter((s) => s.visibility === "public"); + const hasOwnedSpace = mySpaces.some((s) => s.relationship === "owner"); let html = ""; @@ -194,23 +215,24 @@ export class RStackSpaceSwitcher extends HTMLElement { html += `
`; } - // ── Your spaces ── - if (mySpaces.length > 0) { - html += ``; - html += mySpaces - .map((s) => { - const vis = this.#visibilityInfo(s); - const canEdit = s.role === "owner" || s.role === "admin"; - return ` - `; - }) - .join(""); + // ── Private spaces (red) — top ── + if (privateSpaces.length > 0) { + html += ``; + html += this.#renderSpaceGroup(privateSpaces, current, moduleId); + } + + // ── Permissioned spaces (yellow) — middle ── + if (permissionedSpaces.length > 0) { + if (privateSpaces.length > 0) html += `
`; + html += ``; + html += this.#renderSpaceGroup(permissionedSpaces, current, moduleId); + } + + // ── Public spaces (green) — bottom ── + if (publicSpaces.length > 0) { + if (privateSpaces.length > 0 || permissionedSpaces.length > 0) html += `
`; + html += ``; + html += this.#renderSpaceGroup(publicSpaces, current, moduleId); } // ── Discover (permissioned spaces the user can request access to) ── @@ -1094,6 +1116,9 @@ const STYLES = ` padding: 8px 14px 4px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; } +.section-label--private { color: #f87171; opacity: 0.85; } +.section-label--permissioned { color: #fbbf24; opacity: 0.85; } +.section-label--public { color: #34d399; opacity: 0.85; } .item { display: flex; align-items: center; gap: 10px;