fix: deduplicate spaces dropdown, group by visibility type

Personal space (slug=username) forced to private so it doesn't appear
as both public and private. Dropdown now groups spaces: Private (red)
at top, Permissioned (yellow) middle, Public (green) bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 12:00:37 -07:00
parent d96130f919
commit 7177b2882c
2 changed files with 62 additions and 20 deletions

View File

@ -226,15 +226,27 @@ spaces.get("/", async (c) => {
} }
const spacesList = []; const spacesList = [];
const seenSlugs = new Set<string>();
const username = claims?.username?.toLowerCase();
for (const slug of slugs) { for (const slug of slugs) {
await loadCommunity(slug); await loadCommunity(slug);
const data = getDocumentData(slug); const data = getDocumentData(slug);
if (data?.meta) { 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 isOwner = !!(claims && data.meta.ownerDID === claims.sub);
const memberEntry = claims ? data.members?.[claims.sub] : undefined; const memberEntry = claims ? data.members?.[claims.sub] : undefined;
const isMember = !!memberEntry; const isMember = !!memberEntry;
// Owner's personal space (slug matches username) is always private
if (isOwner && username && slug === username) {
vis = "private";
}
// Determine accessibility // Determine accessibility
const isPublicSpace = vis === "public"; const isPublicSpace = vis === "public";
const isPermissioned = vis === "permissioned"; 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<string, number> = { private: 0, permissioned: 1, public: 2 };
spacesList.sort((a, b) => { 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.role && b.role) return 1; if (!a.role && b.role) return 1;
if (a.slug === "demo") return -1; if (a.slug === "demo") return -1;

View File

@ -131,6 +131,23 @@ export class RStackSpaceSwitcher extends HTMLElement {
return s.name; 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 `
<div class="item-row ${vis.cls} ${s.slug === current ? "active" : ""}">
<a class="item" href="${rspaceNavUrl(s.slug, moduleId)}">
<span class="item-icon">${s.icon || "🌐"}</span>
<span class="item-name">${this.#displayName(s)}</span>
<span class="item-vis ${vis.cls}">${vis.label}</span>
</a>${canEdit ? `<button class="item-gear" data-edit-slug="${s.slug}" data-edit-name="${s.name.replace(/"/g, "&quot;")}" title="Edit Space">⚙</button>` : ""}
</div>`;
})
.join("");
}
#demoSpaceHTML(current: string, moduleId: string): string { #demoSpaceHTML(current: string, moduleId: string): string {
const isActive = current === "demo"; const isActive = current === "demo";
return ` return `
@ -171,10 +188,14 @@ export class RStackSpaceSwitcher extends HTMLElement {
return; 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 mySpaces = this.#spaces.filter((s) => s.role);
const discoverSpaces = this.#spaces.filter((s) => s.accessible === false); 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"); const hasOwnedSpace = mySpaces.some((s) => s.relationship === "owner");
let html = ""; let html = "";
@ -194,23 +215,24 @@ export class RStackSpaceSwitcher extends HTMLElement {
html += `<div class="divider"></div>`; html += `<div class="divider"></div>`;
} }
// ── Your spaces ── // ── Private spaces (red) — top ──
if (mySpaces.length > 0) { if (privateSpaces.length > 0) {
html += `<div class="section-label">Your spaces</div>`; html += `<div class="section-label section-label--private">Private</div>`;
html += mySpaces html += this.#renderSpaceGroup(privateSpaces, current, moduleId);
.map((s) => { }
const vis = this.#visibilityInfo(s);
const canEdit = s.role === "owner" || s.role === "admin"; // ── Permissioned spaces (yellow) — middle ──
return ` if (permissionedSpaces.length > 0) {
<div class="item-row ${vis.cls} ${s.slug === current ? "active" : ""}"> if (privateSpaces.length > 0) html += `<div class="divider"></div>`;
<a class="item" href="${rspaceNavUrl(s.slug, moduleId)}"> html += `<div class="section-label section-label--permissioned">Permissioned</div>`;
<span class="item-icon">${s.icon || "🌐"}</span> html += this.#renderSpaceGroup(permissionedSpaces, current, moduleId);
<span class="item-name">${this.#displayName(s)}</span> }
<span class="item-vis ${vis.cls}">${vis.label}</span>
</a>${canEdit ? `<button class="item-gear" data-edit-slug="${s.slug}" data-edit-name="${s.name.replace(/"/g, "&quot;")}" title="Edit Space">⚙</button>` : ""} // ── Public spaces (green) — bottom ──
</div>`; if (publicSpaces.length > 0) {
}) if (privateSpaces.length > 0 || permissionedSpaces.length > 0) html += `<div class="divider"></div>`;
.join(""); html += `<div class="section-label section-label--public">Public</div>`;
html += this.#renderSpaceGroup(publicSpaces, current, moduleId);
} }
// ── Discover (permissioned spaces the user can request access to) ── // ── 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; padding: 8px 14px 4px; font-size: 0.7rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; 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 { .item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;