Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-22 14:35:17 -07:00
commit b97d018292
2 changed files with 32 additions and 98 deletions

View File

@ -241,6 +241,8 @@ spaces.get("/", async (c) => {
seenSlugs.add(slug);
let vis = data.meta.visibility || "private";
// Demo space is always public
if (slug === "demo") vis = "public";
// Check both claims.sub (raw userId) and did:key: format for
// compatibility — auto-provisioned spaces store ownerDID as did:key:
const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : "";
@ -261,9 +263,7 @@ spaces.get("/", async (c) => {
// For unauthenticated: only show demo
if (!claims && slug !== "demo") continue;
// For authenticated: skip public spaces the user has no role in
// (demo is shown separately, other public spaces are noise)
if (claims && isPublicSpace && !isOwner && !isMember && slug !== "demo") continue;
// Public spaces are shown in the PUBLIC section for all authenticated users
// Determine relationship
const relationship = isOwner
@ -290,6 +290,7 @@ spaces.get("/", async (c) => {
accessible,
relationship,
pendingRequest: pendingRequest || undefined,
isPersonal: !!(username && slug === username),
});
}
}

View File

@ -21,6 +21,7 @@ interface SpaceInfo {
accessible?: boolean;
relationship?: "owner" | "member" | "demo" | "other";
pendingRequest?: boolean;
isPersonal?: boolean;
}
export class RStackSpaceSwitcher extends HTMLElement {
@ -141,10 +142,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
return { cls: "vis-public", label: "👁" };
}
/** Format display name based on visibility type */
/** Format display name — only rename the user's personal space */
#displayName(s: SpaceInfo): string {
const v = s.visibility || "public";
if (v === "private") {
if (s.isPersonal || s.slug === getUsername()?.toLowerCase()) {
const username = getUsername();
return username ? `${username}'s Space` : "My Space";
}
@ -168,130 +168,60 @@ export class RStackSpaceSwitcher extends HTMLElement {
.join("");
}
#demoSpaceHTML(current: string, moduleId: string): string {
const isActive = current === "demo";
return `
<a class="item vis-public ${isActive ? "active" : ""}"
href="${rspaceNavUrl("demo", moduleId)}">
<span class="item-icon">🎮</span>
<span class="item-name">Demo Space</span>
<span class="item-vis vis-public">👁</span>
</a>`;
}
#renderMenu(menu: HTMLElement, current: string) {
const auth = isAuthenticated();
const moduleId = this.#getCurrentModule();
if (this.#spaces.length === 0) {
let cta = "";
if (!auth) {
cta = this.#yourSpaceCTAhtml("Sign in to create →");
} else {
const username = getUsername();
const label = username ? `Create ${username}'s Space →` : "Create My Space →";
cta = this.#yourSpaceCTAhtml(label);
}
menu.innerHTML = `
${this.#demoSpaceHTML(current, moduleId)}
<div class="divider"></div>
${cta}
<div class="divider"></div>
<div class="menu-empty">
${auth ? "No spaces yet" : "Sign in to see your spaces"}
</div>
<button class="item item--create" id="create-toggle">+ Create new space</button>
${this.#createFormHTML()}
`;
this.#attachYourSpaceCTA(menu);
this.#attachCreatePopout(menu);
return;
}
// Split spaces by visibility
const privateSpaces = this.#spaces.filter((s) => s.visibility === "private");
const permissionedSpaces = this.#spaces.filter((s) => s.visibility === "permissioned");
const publicSpaces = this.#spaces.filter((s) => s.visibility === "public");
// 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");
const hasOwnedSpace = this.#spaces.some((s) => s.relationship === "owner");
let html = "";
// ── Demo Space — always first ──
html += this.#demoSpaceHTML(current, moduleId);
html += `<div class="divider"></div>`;
// ── Create personal space CTA — only if user has no owned spaces ──
// ── PRIVATE section ──
html += `<div class="section-label section-label--private">Private</div>`;
if (privateSpaces.length > 0) {
html += this.#renderSpaceGroup(privateSpaces, current, moduleId);
}
if (!auth) {
html += this.#yourSpaceCTAhtml("Sign in to create →");
html += `<div class="divider"></div>`;
} else if (!hasOwnedSpace) {
const username = getUsername();
const label = username ? `Create ${username}'s Space →` : "Create My Space →";
html += this.#yourSpaceCTAhtml(label);
html += `<div class="divider"></div>`;
}
// ── 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 ──
// ── PERMISSIONED section ──
if (permissionedSpaces.length > 0) {
if (privateSpaces.length > 0) html += `<div class="divider"></div>`;
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 ──
// ── PUBLIC section ──
html += `<div class="divider"></div>`;
html += `<div class="section-label section-label--public">Public</div>`;
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);
} else {
html += `<div class="menu-empty">No public spaces</div>`;
}
// ── Discover (permissioned spaces the user can request access to) ──
if (auth && discoverSpaces.length > 0) {
html += `<div class="divider"></div>`;
html += `<div class="section-label">Discover</div>`;
html += discoverSpaces
.map((s) => {
const vis = this.#visibilityInfo(s);
const pending = s.pendingRequest;
return `
<div class="item item--discover ${vis.cls}">
<span class="item-icon">${s.icon || "🌐"}</span>
<span class="item-name">${this.#displayName(s)}</span>
<span class="item-vis ${vis.cls}">${vis.label}</span>
${pending
? `<span class="item-badge item-badge--pending">Requested</span>`
: `<button class="item-request-btn" data-slug="${s.slug}" data-name="${s.name.replace(/"/g, "&quot;")}">Request Access</button>`
}
</div>`;
})
.join("");
}
// ── *Discover section ──
html += `<div class="divider"></div>`;
html += `<div class="section-label section-label--discover">✦ Discover</div>`;
html += `<a class="item item--discover-link" href="/discover"><span class="item-name">Explore spaces →</span></a>`;
// ── Create new space ──
html += `<div class="divider"></div>`;
html += `<button class="item item--create" id="create-toggle">+ Create new space</button>`;
html += this.#createFormHTML();
menu.innerHTML = html;
// Attach Request Access button listeners
menu.querySelectorAll(".item-request-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const el = btn as HTMLElement;
this.#showRequestAccessModal(el.dataset.slug!, el.dataset.name!);
});
});
// Intercept space link clicks — dispatch space-switch event for client-side switching
menu.querySelectorAll("a.item[href]").forEach((link) => {
link.addEventListener("click", (e) => {
@ -1176,6 +1106,9 @@ const STYLES = `
.section-label--private { color: #f87171; opacity: 0.85; }
.section-label--permissioned { color: #fbbf24; opacity: 0.85; }
.section-label--public { color: #34d399; opacity: 0.85; }
.section-label--discover { color: #a78bfa; opacity: 0.85; }
.item--discover-link { font-size: 0.85rem; color: #a78bfa; text-decoration: none; }
.item--discover-link:hover { background: rgba(167,139,250,0.08); }
.item {
display: flex; align-items: center; gap: 10px;