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 seenSlugs = new Set<string>();
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<string, number> = { 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;

View File

@ -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 `
<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 {
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 += `<div class="divider"></div>`;
}
// ── Your spaces ──
if (mySpaces.length > 0) {
html += `<div class="section-label">Your spaces</div>`;
html += mySpaces
.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("");
// ── Private spaces (red) — top ──
if (privateSpaces.length > 0) {
html += `<div class="section-label section-label--private">Private</div>`;
html += this.#renderSpaceGroup(privateSpaces, current, moduleId);
}
// ── Permissioned spaces (yellow) — middle ──
if (permissionedSpaces.length > 0) {
if (privateSpaces.length > 0) html += `<div class="divider"></div>`;
html += `<div class="section-label section-label--permissioned">Permissioned</div>`;
html += this.#renderSpaceGroup(permissionedSpaces, current, moduleId);
}
// ── Public spaces (green) — bottom ──
if (publicSpaces.length > 0) {
if (privateSpaces.length > 0 || permissionedSpaces.length > 0) html += `<div class="divider"></div>`;
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) ──
@ -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;