refactor: consolidate space settings — both gear icons open same tabbed modal

Remove rstack-space-settings slide-out panel. Enhance the existing tabbed
modal in rstack-space-switcher with add-member (username search + email
invite) and pending email invites (with revoke). Header gear now calls
openSettingsModal() on the space switcher instead of toggling the old panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 10:58:30 -07:00
parent 2cbff8925d
commit 690e4dedb4
14 changed files with 416 additions and 1342 deletions

View File

@ -85,7 +85,6 @@ function extractCanvasContent(html: string): { body: string; styles: string; scr
// space-settings, history-panel, and welcome overlay.
bodyContent = bodyContent
.replace(/<header[\s\S]*?<\/header>/i, "")
.replace(/<rstack-space-settings[^>]*><\/rstack-space-settings>/gi, "")
.replace(/<rstack-history-panel[^>]*><\/rstack-history-panel>/gi, "")
.replace(/<!--\s*Welcome overlay[\s\S]*?<\/div>\s*<\/div>\s*<\/div>/i, "");
// Strip the rstack-tab-row div (nested divs make simple regex unreliable)

View File

@ -132,7 +132,7 @@ export class RStackAppSwitcher extends HTMLElement {
#isOpen = false;
#catalogOpen = false;
#catalogBusy = false;
#outsideClickHandler: ((e: MouseEvent) => void) | null = null;
#outsideClickHandler: ((e: PointerEvent) => void) | null = null;
constructor() {
super();
@ -173,7 +173,7 @@ export class RStackAppSwitcher extends HTMLElement {
// Clean up body class and outside-click listener on removal
document.body.classList.remove("rstack-sidebar-open");
if (this.#outsideClickHandler) {
document.removeEventListener("click", this.#outsideClickHandler);
document.removeEventListener("pointerdown", this.#outsideClickHandler);
this.#outsideClickHandler = null;
}
}
@ -404,16 +404,16 @@ export class RStackAppSwitcher extends HTMLElement {
// Close sidebar when clicking outside (on main content)
if (this.#outsideClickHandler) {
document.removeEventListener("click", this.#outsideClickHandler);
document.removeEventListener("pointerdown", this.#outsideClickHandler);
}
this.#outsideClickHandler = (e: MouseEvent) => {
this.#outsideClickHandler = (e: PointerEvent) => {
if (!this.#isOpen) return;
const path = e.composedPath();
if (!path.includes(sidebar) && !path.includes(trigger)) {
close();
}
};
document.addEventListener("click", this.#outsideClickHandler);
document.addEventListener("pointerdown", this.#outsideClickHandler);
// Prefetch module fragments on hover for faster tab switching.
// Uses low-priority fetch so it doesn't compete with user-initiated requests.
@ -589,7 +589,7 @@ const STYLES = `
white-space: nowrap; min-width: 0; flex-shrink: 1;
overflow: hidden; text-overflow: ellipsis;
}
.trigger:hover { background: var(--rs-bg-hover); }
.trigger:hover, .trigger:active { background: var(--rs-bg-hover); }
.trigger-badge {
display: inline-flex; align-items: center; justify-content: center;
@ -609,6 +609,7 @@ const STYLES = `
top: 56px; left: 0; bottom: 0;
width: 280px;
overflow-y: auto;
touch-action: pan-y;
z-index: 10001;
background: var(--rs-bg-surface);
border-right: 1px solid var(--rs-border);
@ -675,7 +676,7 @@ a.rstack-header:hover { background: var(--rs-bg-hover); }
transition: background 0.12s;
color: var(--rs-text-primary);
}
.item-row:hover { background: var(--rs-bg-hover); }
.item-row:hover, .item-row:active { background: var(--rs-bg-hover); }
.item-row.active { background: var(--rs-bg-active); }
.item {

View File

@ -862,7 +862,7 @@ const OVERLAY_CSS = `
white-space: nowrap;
}
.collab-badge:hover {
.collab-badge:hover, .collab-badge:active {
border-color: var(--rs-border-strong, rgba(255,255,255,0.2));
}
@ -938,6 +938,7 @@ const OVERLAY_CSS = `
.people-list {
overflow-y: auto;
touch-action: pan-y;
flex: 1;
padding: 8px;
}
@ -959,7 +960,7 @@ const OVERLAY_CSS = `
transition: background 0.15s;
}
.people-row:hover {
.people-row:hover, .people-row:active {
background: var(--rs-bg-hover, rgba(0,0,0,0.04));
}

View File

@ -213,10 +213,10 @@ export class RStackCommentBell extends HTMLElement {
this.#render();
}
};
document.addEventListener("click", closeHandler, { once: true });
document.addEventListener("pointerdown", closeHandler, { once: true });
// Stop propagation from panel clicks
this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation());
this.#shadow.querySelector(".panel")?.addEventListener("pointerdown", (e) => e.stopPropagation());
}
// New Comment button
@ -291,7 +291,7 @@ const STYLES = `
justify-content: center;
transition: color 0.15s, background 0.15s;
}
.comment-btn:hover {
.comment-btn:hover, .comment-btn:active {
color: var(--rs-text-primary, #e2e8f0);
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
}
@ -364,13 +364,14 @@ const STYLES = `
cursor: pointer;
transition: opacity 0.15s;
}
.new-comment-btn:hover {
.new-comment-btn:hover, .new-comment-btn:active {
opacity: 0.85;
}
.panel-body {
flex: 1;
overflow-y: auto;
touch-action: pan-y;
}
.panel-empty {
@ -389,7 +390,7 @@ const STYLES = `
transition: background 0.15s;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
}
.comment-item:hover {
.comment-item:hover, .comment-item:active {
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
}
.comment-item:last-child {

View File

@ -684,7 +684,7 @@ export class RStackIdentity extends HTMLElement {
dropdown.classList.toggle("open");
});
document.addEventListener("click", () => dropdown.classList.remove("open"));
document.addEventListener("pointerdown", () => dropdown.classList.remove("open"));
this.#shadow.querySelectorAll("[data-action]").forEach((el) => {
el.addEventListener("click", (e) => {

View File

@ -249,7 +249,7 @@ export class RStackMi extends HTMLElement {
});
// Close panel on outside click — use composedPath to pierce Shadow DOM
document.addEventListener("click", (e) => {
document.addEventListener("pointerdown", (e) => {
const path = e.composedPath();
if (!path.includes(this)) {
panel.classList.remove("open");
@ -258,8 +258,8 @@ export class RStackMi extends HTMLElement {
});
// Prevent internal clicks from closing
panel.addEventListener("mousedown", (e) => e.stopPropagation());
bar.addEventListener("mousedown", (e) => e.stopPropagation());
panel.addEventListener("pointerdown", (e) => e.stopPropagation());
bar.addEventListener("pointerdown", (e) => e.stopPropagation());
// Minimize button
this.#shadow.getElementById("mi-minimize")!.addEventListener("click", () => this.#minimize());
@ -855,7 +855,7 @@ const STYLES = `
font-size: 0.85rem; border-radius: 6px; transition: all 0.2s;
flex-shrink: 0; line-height: 1;
}
.mi-mic-btn:hover { background: var(--rs-bg-hover); }
.mi-mic-btn:hover, .mi-mic-btn:active { background: var(--rs-bg-hover); }
.mi-mic-btn.recording {
animation: micPulse 1.5s infinite;
filter: saturate(2) brightness(1.1);
@ -907,7 +907,7 @@ const STYLES = `
font-size: 1.1rem; line-height: 1; padding: 2px 6px; border-radius: 4px;
color: var(--rs-text-muted); transition: all 0.15s;
}
.mi-panel-btn:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
.mi-panel-btn:hover, .mi-panel-btn:active { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
/* ── Messages ── */
.mi-messages {
@ -935,7 +935,7 @@ const STYLES = `
font-family: inherit; white-space: nowrap;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
}
.mi-suggest-chip:hover {
.mi-suggest-chip:hover, .mi-suggest-chip:active {
background: var(--rs-bg-hover);
border-color: rgba(6,182,212,0.4);
box-shadow: 0 0 0 1px rgba(6,182,212,0.15);
@ -943,7 +943,7 @@ const STYLES = `
.mi-suggest-chip.dynamic {
border-color: rgba(124,58,237,0.3);
}
.mi-suggest-chip.dynamic:hover {
.mi-suggest-chip.dynamic:hover, .mi-suggest-chip.dynamic:active {
border-color: rgba(124,58,237,0.5);
box-shadow: 0 0 0 1px rgba(124,58,237,0.15);
}
@ -1022,7 +1022,7 @@ const STYLES = `
font-family: inherit;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
}
.mi-tool-chip:hover { background: var(--rs-bg-hover); }
.mi-tool-chip:hover, .mi-tool-chip:active { background: var(--rs-bg-hover); }
/* ── Confirmation bar ── */
.mi-confirm {
@ -1080,7 +1080,7 @@ const STYLES = `
font-size: 0.9rem; padding: 4px 8px; border-radius: 6px;
color: #06b6d4; transition: all 0.15s; flex-shrink: 0;
}
.mi-send-btn:hover { background: rgba(6,182,212,0.12); }
.mi-send-btn:hover, .mi-send-btn:active { background: rgba(6,182,212,0.12); }
/* ── Minimized pill ── */
.mi-pill {
@ -1092,7 +1092,7 @@ const STYLES = `
display: none; align-items: center; gap: 6px;
color: var(--rs-text-primary); transition: all 0.2s;
}
.mi-pill:hover { box-shadow: 0 4px 20px rgba(6,182,212,0.3); }
.mi-pill:hover, .mi-pill:active { box-shadow: 0 4px 20px rgba(6,182,212,0.3); }
.mi-pill.visible { display: flex; }
.mi-pill-icon {
background: linear-gradient(135deg, #06b6d4, #7c3aed);

View File

@ -428,10 +428,10 @@ export class RStackNotificationBell extends HTMLElement {
this.#render();
}
};
document.addEventListener("click", closeHandler, { once: true });
document.addEventListener("pointerdown", closeHandler, { once: true });
// Stop propagation from panel clicks
this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation());
this.#shadow.querySelector(".panel")?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Mark all read
this.#shadow.querySelector('[data-action="mark-all-read"]')?.addEventListener("click", (e) => {
@ -520,7 +520,7 @@ const STYLES = `
justify-content: center;
transition: color 0.15s, background 0.15s;
}
.bell-btn:hover {
.bell-btn:hover, .bell-btn:active {
color: var(--rs-text-primary, #e2e8f0);
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
}
@ -552,6 +552,7 @@ const STYLES = `
width: 360px;
max-height: 480px;
overflow-y: auto;
touch-action: pan-y;
border-radius: 10px;
background: var(--rs-bg-surface, #1e293b);
border: 1px solid var(--rs-border, rgba(255,255,255,0.1));
@ -589,7 +590,7 @@ const STYLES = `
border-radius: 4px;
transition: background 0.15s;
}
.mark-all-btn:hover, .push-btn:hover {
.mark-all-btn:hover, .push-btn:hover, .mark-all-btn:active, .push-btn:active {
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
}
@ -614,7 +615,7 @@ const STYLES = `
transition: background 0.15s;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1));
}
.notif-item:hover {
.notif-item:hover, .notif-item:active {
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
}
.notif-item.unread {
@ -687,7 +688,7 @@ const STYLES = `
border: 1px solid var(--rs-border, rgba(255,255,255,0.15));
color: var(--rs-text-muted, #94a3b8);
}
.notif-accept:hover, .notif-decline:hover {
.notif-accept:hover, .notif-decline:hover, .notif-accept:active, .notif-decline:active {
opacity: 0.85;
}
@ -706,7 +707,7 @@ const STYLES = `
.notif-item:hover .notif-dismiss {
opacity: 1;
}
.notif-dismiss:hover {
.notif-dismiss:hover, .notif-dismiss:active {
color: var(--rs-text-primary, #e2e8f0);
}

View File

@ -167,11 +167,11 @@ export class RStackSharePanel extends HTMLElement {
});
// Stop propagation from panel clicks
this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation());
this.#shadow.querySelector(".panel")?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Close on outside click
if (this.#open) {
document.addEventListener("click", () => {
document.addEventListener("pointerdown", () => {
if (this.#open) {
this.#open = false;
this.#render();

File diff suppressed because it is too large Load Diff

View File

@ -123,7 +123,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
});
// Close menu only when clicking outside both trigger and menu
document.addEventListener("click", (e) => {
document.addEventListener("pointerdown", (e) => {
if (!menu.classList.contains("open")) return;
const path = e.composedPath();
if (path.includes(menu) || path.includes(trigger)) return;
@ -132,7 +132,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
});
// Prevent clicks inside menu from closing it
menu.addEventListener("click", (e) => e.stopPropagation());
menu.addEventListener("pointerdown", (e) => e.stopPropagation());
}
#visibilityInfo(s: SpaceInfo): { cls: string; label: string } {
@ -517,7 +517,12 @@ export class RStackSpaceSwitcher extends HTMLElement {
document.body.appendChild(overlay);
}
#showEditSpaceModal(slug: string, spaceName: string) {
/** Public entry point — both gear icons call this */
openSettingsModal(slug: string, name: string, tab: "settings" | "modules" | "members" | "invitations" = "settings") {
this.#showEditSpaceModal(slug, name, tab);
}
#showEditSpaceModal(slug: string, spaceName: string, initialTab: "settings" | "modules" | "members" | "invitations" = "settings") {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
@ -528,13 +533,13 @@ export class RStackSpaceSwitcher extends HTMLElement {
<button class="close-btn" data-action="close">&times;</button>
<h2>Edit Space</h2>
<div class="tabs">
<button class="tab active" data-tab="settings">Settings</button>
<button class="tab" data-tab="modules">Modules</button>
<button class="tab" data-tab="members">Members</button>
<button class="tab" data-tab="invitations">Invitations</button>
<button class="tab ${initialTab === "settings" ? "active" : ""}" data-tab="settings">Settings</button>
<button class="tab ${initialTab === "modules" ? "active" : ""}" data-tab="modules">Modules</button>
<button class="tab ${initialTab === "members" ? "active" : ""}" data-tab="members">Members</button>
<button class="tab ${initialTab === "invitations" ? "active" : ""}" data-tab="invitations">Invitations</button>
</div>
<div class="tab-panel" id="panel-settings">
<div class="tab-panel ${initialTab !== "settings" ? "hidden" : ""}" id="panel-settings">
<label class="field-label">Name</label>
<input class="input" id="es-name" value="${spaceName.replace(/"/g, "&quot;")}" />
<label class="field-label">Visibility</label>
@ -555,16 +560,16 @@ export class RStackSpaceSwitcher extends HTMLElement {
</div>
</div>
<div class="tab-panel hidden" id="panel-modules">
<div class="tab-panel ${initialTab !== "modules" ? "hidden" : ""}" id="panel-modules">
<div id="es-modules-list" class="modules-list"><div class="loading">Loading modules...</div></div>
<div class="status" id="es-modules-status"></div>
</div>
<div class="tab-panel hidden" id="panel-members">
<div class="tab-panel ${initialTab !== "members" ? "hidden" : ""}" id="panel-members">
<div id="es-members-list" class="member-list"><div class="loading">Loading members...</div></div>
</div>
<div class="tab-panel hidden" id="panel-invitations">
<div class="tab-panel ${initialTab !== "invitations" ? "hidden" : ""}" id="panel-invitations">
<div id="es-invitations-list" class="invitation-list"><div class="loading">Loading invitations...</div></div>
</div>
</div>
@ -601,6 +606,11 @@ export class RStackSpaceSwitcher extends HTMLElement {
// Delete handler
overlay.querySelector("#es-delete")?.addEventListener("click", () => this.#deleteSpace(overlay, slug, spaceName));
// Eager-load the initially-active tab (if not settings, which loads above)
if (initialTab === "modules") this.#loadModulesConfig(overlay, slug);
if (initialTab === "members") this.#loadMembers(overlay, slug);
if (initialTab === "invitations") this.#loadInvitations(overlay, slug);
document.body.appendChild(overlay);
}
@ -691,29 +701,73 @@ export class RStackSpaceSwitcher extends HTMLElement {
const data = await res.json();
const members: Array<{ did: string; role: string; displayName?: string; isOwner?: boolean }> = data.members || [];
let membersHtml = "";
if (members.length === 0) {
container.innerHTML = `<div class="loading">No members</div>`;
return;
membersHtml = `<div class="loading">No members</div>`;
} else {
membersHtml = members.map((m) => {
const displayName = m.displayName || m.did.slice(0, 20) + "...";
const roleOptions = ["viewer", "member", "moderator", "admin"]
.map((r) => `<option value="${r}" ${r === m.role ? "selected" : ""}>${r}</option>`)
.join("");
return `
<div class="member-item${m.isOwner ? " member-owner" : ""}">
<span class="member-name">${displayName}</span>
${m.isOwner
? `<span class="role-badge role-owner">owner</span>`
: `<select class="role-select" data-did="${m.did}">${roleOptions}</select>
<button class="member-remove" data-did="${m.did}" title="Remove member">&times;</button>`
}
</div>`;
}).join("");
}
container.innerHTML = members.map((m) => {
const displayName = m.displayName || m.did.slice(0, 20) + "...";
const roleOptions = ["viewer", "member", "moderator", "admin"]
.map((r) => `<option value="${r}" ${r === m.role ? "selected" : ""}>${r}</option>`)
.join("");
return `
<div class="member-item${m.isOwner ? " member-owner" : ""}">
<span class="member-name">${displayName}</span>
${m.isOwner
? `<span class="role-badge role-owner">owner</span>`
: `<select class="role-select" data-did="${m.did}">${roleOptions}</select>
<button class="member-remove" data-did="${m.did}" title="Remove member">&times;</button>`
}
</div>`;
}).join("");
// Add Member section (admin-only)
const addMemberHtml = `
<div class="add-member-section">
<div class="section-label">Add Member</div>
<div class="add-member-toggle">
<button class="add-toggle-btn active" data-add-mode="username">By Username</button>
<button class="add-toggle-btn" data-add-mode="email">By Email</button>
</div>
<div class="add-member-form" id="add-member-form">
<div class="add-member-username-mode">
<div class="add-search-wrapper">
<input class="input" id="am-username" placeholder="Search username..." autocomplete="off" style="margin-bottom:0" />
<div class="add-search-dropdown" id="am-search-dropdown"></div>
</div>
<div class="add-selected-user" id="am-selected-user"></div>
<div class="add-member-row">
<select class="role-select" id="am-role">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button class="btn-sm btn-sm--approve" id="am-add-btn" disabled>Add</button>
</div>
<div class="add-feedback" id="am-feedback"></div>
</div>
</div>
<div class="add-member-form hidden" id="add-email-form">
<input type="email" class="input" id="am-email" placeholder="Email address..." style="margin-bottom:0" />
<div class="add-member-row">
<select class="role-select" id="am-email-role">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button class="btn-sm btn-sm--approve" id="am-email-btn">Send Invite</button>
</div>
<div class="add-feedback" id="am-email-feedback"></div>
</div>
</div>`;
container.innerHTML = membersHtml + addMemberHtml;
// Role change handlers
container.querySelectorAll(".role-select").forEach((sel) => {
container.querySelectorAll(".role-select:not(#am-role):not(#am-email-role)").forEach((sel) => {
sel.addEventListener("change", async () => {
const el = sel as HTMLSelectElement;
const did = el.dataset.did!;
@ -746,51 +800,238 @@ export class RStackSpaceSwitcher extends HTMLElement {
} catch { alert("Failed to remove member"); }
});
});
// Wire up add-member section
this.#attachAddMemberHandlers(container, overlay, slug);
} catch {
container.innerHTML = `<div class="loading">Failed to load members</div>`;
}
}
#attachAddMemberHandlers(container: HTMLElement, overlay: HTMLElement, slug: string) {
// Toggle between username and email modes
const usernameForm = container.querySelector("#add-member-form") as HTMLElement;
const emailForm = container.querySelector("#add-email-form") as HTMLElement;
container.querySelectorAll(".add-toggle-btn").forEach((btn) => {
btn.addEventListener("click", () => {
container.querySelectorAll(".add-toggle-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const mode = (btn as HTMLElement).dataset.addMode;
if (mode === "username") {
usernameForm?.classList.remove("hidden");
emailForm?.classList.add("hidden");
} else {
usernameForm?.classList.add("hidden");
emailForm?.classList.remove("hidden");
}
});
});
// Username search with debounce
const usernameInput = container.querySelector("#am-username") as HTMLInputElement;
const dropdown = container.querySelector("#am-search-dropdown") as HTMLElement;
const selectedDiv = container.querySelector("#am-selected-user") as HTMLElement;
const addBtn = container.querySelector("#am-add-btn") as HTMLButtonElement;
const feedback = container.querySelector("#am-feedback") as HTMLElement;
let selectedUser: { did: string; username: string; displayName: string } | null = null;
let searchTimer: ReturnType<typeof setTimeout>;
if (usernameInput) {
usernameInput.addEventListener("input", () => {
clearTimeout(searchTimer);
selectedUser = null;
addBtn.disabled = true;
selectedDiv.innerHTML = "";
const q = usernameInput.value.trim();
if (q.length < 2) { dropdown.innerHTML = ""; dropdown.classList.remove("open"); return; }
searchTimer = setTimeout(async () => {
try {
const token = getAccessToken();
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}&limit=5`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) { dropdown.classList.remove("open"); return; }
const users: Array<{ id: string; did: string; username: string; displayName: string }> = await res.json();
if (users.length === 0) {
dropdown.innerHTML = `<div class="add-dd-empty">No users found</div>`;
} else {
dropdown.innerHTML = users.map((u, i) => `
<div class="add-dd-item" data-idx="${i}">
<span class="add-dd-name">${u.displayName || u.username}</span>
<span class="add-dd-user">@${u.username}</span>
</div>
`).join("");
// Attach click handlers
dropdown.querySelectorAll(".add-dd-item").forEach((item) => {
item.addEventListener("click", () => {
const idx = parseInt((item as HTMLElement).dataset.idx || "0");
const u = users[idx];
selectedUser = { did: u.id, username: u.username, displayName: u.displayName || u.username };
usernameInput.value = u.username;
dropdown.innerHTML = "";
dropdown.classList.remove("open");
selectedDiv.innerHTML = `<span class="add-selected-chip">Selected: <strong>${selectedUser.displayName}</strong> (@${selectedUser.username})</span>`;
addBtn.disabled = false;
});
});
}
dropdown.classList.add("open");
} catch { dropdown.classList.remove("open"); }
}, 300);
});
// Close dropdown on outside click
overlay.addEventListener("click", (e) => {
if (!(e.target as HTMLElement).closest(".add-search-wrapper")) {
dropdown.classList.remove("open");
}
});
}
// Add by username
addBtn?.addEventListener("click", async () => {
if (!selectedUser) return;
const role = (container.querySelector("#am-role") as HTMLSelectElement).value;
const token = getAccessToken();
if (!token) return;
try {
const res = await fetch(`/api/spaces/${slug}/members/add`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ username: selectedUser.username, role }),
});
if (res.ok) {
feedback.textContent = `Invite sent to ${selectedUser.username}`;
feedback.style.color = "#34d399";
selectedUser = null;
usernameInput.value = "";
selectedDiv.innerHTML = "";
addBtn.disabled = true;
setTimeout(() => { feedback.textContent = ""; }, 3000);
this.#loadMembers(overlay, slug);
} else {
const err = await res.json();
feedback.textContent = err.error || "Failed to invite";
feedback.style.color = "#ef4444";
setTimeout(() => { feedback.textContent = ""; }, 3000);
}
} catch {
feedback.textContent = "Network error";
feedback.style.color = "#ef4444";
setTimeout(() => { feedback.textContent = ""; }, 3000);
}
});
// Add by email
const emailInput = container.querySelector("#am-email") as HTMLInputElement;
const emailBtn = container.querySelector("#am-email-btn") as HTMLButtonElement;
const emailFeedback = container.querySelector("#am-email-feedback") as HTMLElement;
emailBtn?.addEventListener("click", async () => {
if (!emailInput?.value) return;
const role = (container.querySelector("#am-email-role") as HTMLSelectElement).value;
const token = getAccessToken();
if (!token) return;
try {
const res = await fetch(`/api/spaces/${slug}/invite`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ email: emailInput.value, role }),
});
if (res.ok) {
emailFeedback.textContent = "Invite sent";
emailFeedback.style.color = "#34d399";
emailInput.value = "";
setTimeout(() => { emailFeedback.textContent = ""; }, 3000);
} else {
const err = await res.json().catch(() => ({ error: "Failed to invite" })) as { error?: string };
emailFeedback.textContent = err.error || "Failed to invite";
emailFeedback.style.color = "#ef4444";
setTimeout(() => { emailFeedback.textContent = ""; }, 3000);
}
} catch {
emailFeedback.textContent = "Network error";
emailFeedback.style.color = "#ef4444";
setTimeout(() => { emailFeedback.textContent = ""; }, 3000);
}
});
}
async #loadInvitations(overlay: HTMLElement, slug: string) {
const container = overlay.querySelector("#es-invitations-list") as HTMLElement;
try {
const token = getAccessToken();
const res = await fetch(`/api/spaces/${slug}/access-requests`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) { container.innerHTML = `<div class="loading">Failed to load</div>`; return; }
const data = await res.json();
const requests: Array<{ id: string; requesterUsername: string; message?: string; status: string; createdAt: number }> = data.requests || [];
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (requests.length === 0) {
container.innerHTML = `<div class="loading">No access requests</div>`;
return;
}
const pending = requests.filter((r) => r.status === "pending");
const resolved = requests.filter((r) => r.status !== "pending");
// Fetch access requests and email invites in parallel
const [reqRes, invRes] = await Promise.all([
fetch(`/api/spaces/${slug}/access-requests`, { headers }),
fetch(`/api/spaces/${slug}/invites`, { headers }).catch(() => null),
]);
// Access requests
let html = "";
if (pending.length > 0) {
html += pending.map((r) => `
<div class="invite-item">
<div class="invite-info">
if (reqRes.ok) {
const data = await reqRes.json();
const requests: Array<{ id: string; requesterUsername: string; message?: string; status: string; createdAt: number }> = data.requests || [];
const pending = requests.filter((r) => r.status === "pending");
const resolved = requests.filter((r) => r.status !== "pending");
html += `<div class="section-label">Access Requests</div>`;
if (pending.length > 0) {
html += pending.map((r) => `
<div class="invite-item">
<div class="invite-info">
<span class="invite-name">${r.requesterUsername}</span>
${r.message ? `<span class="invite-msg">${r.message.replace(/</g, "&lt;")}</span>` : ""}
</div>
<div class="invite-actions">
<button class="btn-sm btn-sm--approve" data-req-id="${r.id}" data-action="approve">Approve</button>
<button class="btn-sm btn-sm--deny" data-req-id="${r.id}" data-action="deny">Deny</button>
</div>
</div>`).join("");
} else {
html += `<div class="loading" style="padding:8px 0">No pending requests</div>`;
}
if (resolved.length > 0) {
html += `<div class="section-label" style="opacity:0.4;padding:8px 0 4px">Resolved</div>`;
html += resolved.map((r) => `
<div class="invite-item invite-resolved">
<span class="invite-name">${r.requesterUsername}</span>
${r.message ? `<span class="invite-msg">${r.message.replace(/</g, "&lt;")}</span>` : ""}
</div>
<div class="invite-actions">
<button class="btn-sm btn-sm--approve" data-req-id="${r.id}" data-action="approve">Approve</button>
<button class="btn-sm btn-sm--deny" data-req-id="${r.id}" data-action="deny">Deny</button>
</div>
</div>`).join("");
<span class="invite-status invite-status--${r.status}">${r.status}</span>
</div>`).join("");
}
} else {
html += `<div class="loading">Failed to load access requests</div>`;
}
if (resolved.length > 0) {
html += `<div class="section-label" style="opacity:0.4;padding:8px 0 4px">Resolved</div>`;
html += resolved.map((r) => `
<div class="invite-item invite-resolved">
<span class="invite-name">${r.requesterUsername}</span>
<span class="invite-status invite-status--${r.status}">${r.status}</span>
</div>`).join("");
// Email invites
html += `<div class="section-label" style="margin-top:16px">Pending Email Invites</div>`;
if (invRes && invRes.ok) {
const invData = await invRes.json();
const invites: Array<{ id: string; email?: string; role: string; status: string; expiresAt: string }> = invData.invites || [];
const pendingInvites = invites.filter((inv) => inv.status === "pending");
if (pendingInvites.length > 0) {
html += pendingInvites.map((inv) => {
const expiry = new Date(inv.expiresAt).toLocaleDateString();
return `
<div class="invite-item">
<div class="invite-info">
<span class="invite-name">${(inv.email || "Link invite").replace(/</g, "&lt;")}</span>
<span class="invite-msg">${inv.role} &middot; expires ${expiry}</span>
</div>
<button class="btn-sm btn-sm--deny" data-revoke-id="${inv.id}">Revoke</button>
</div>`;
}).join("");
} else {
html += `<div class="loading" style="padding:8px 0">No pending invites</div>`;
}
} else {
html += `<div class="loading" style="padding:8px 0">No pending invites</div>`;
}
container.innerHTML = html;
@ -813,6 +1054,23 @@ export class RStackSpaceSwitcher extends HTMLElement {
} catch { alert("Failed to process request"); }
});
});
// Revoke invite handlers
container.querySelectorAll("[data-revoke-id]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = (btn as HTMLElement).dataset.revokeId!;
const token = getAccessToken();
if (!token) return;
try {
const res = await fetch(`/api/spaces/${slug}/invites/${id}/revoke`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) { const d = await res.json().catch(() => ({})); alert((d as any).error || "Failed"); return; }
this.#loadInvitations(overlay, slug);
} catch { alert("Failed to revoke invite"); }
});
});
} catch {
container.innerHTML = `<div class="loading">Failed to load invitations</div>`;
}
@ -1919,6 +2177,32 @@ select.input { appearance: auto; }
input:checked + .toggle-slider { background: #06b6d4; }
input:checked + .toggle-slider:before { transform: translateX(16px); }
/* Section labels */
.section-label { font-size: 0.72rem; font-weight: 700; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; padding: 8px 0 4px; }
/* Add member section */
.add-member-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--rs-border-subtle); }
.add-member-toggle { display: flex; gap: 4px; margin-bottom: 10px; }
.add-toggle-btn {
flex: 1; padding: 6px 10px; background: var(--rs-bg-hover); border: 1px solid var(--rs-input-border, #404040);
color: var(--rs-text-secondary); border-radius: 6px; font-size: 0.78rem; cursor: pointer; transition: all 0.15s;
}
.add-toggle-btn.active { background: rgba(6,182,212,0.1); border-color: rgba(6,182,212,0.3); color: #06b6d4; }
.add-member-form { display: flex; flex-direction: column; gap: 8px; }
.add-member-form.hidden { display: none; }
.add-member-row { display: flex; gap: 8px; align-items: center; }
.add-search-wrapper { position: relative; }
.add-search-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; margin-top: 4px; overflow: hidden; z-index: 10; box-shadow: 0 4px 16px rgba(0,0,0,0.25); display: none; }
.add-search-dropdown.open { display: block; }
.add-dd-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; transition: background 0.1s; }
.add-dd-item:hover { background: var(--rs-bg-hover); }
.add-dd-name { font-size: 0.82rem; font-weight: 500; color: var(--rs-text-primary); }
.add-dd-user { font-size: 0.72rem; color: var(--rs-text-muted); }
.add-dd-empty { padding: 8px 12px; font-size: 0.8rem; color: var(--rs-text-muted); }
.add-selected-chip { font-size: 0.78rem; color: #06b6d4; }
.add-selected-user { min-height: 0; }
.add-feedback { font-size: 0.78rem; min-height: 16px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
`;

View File

@ -1182,9 +1182,9 @@ export class RStackTabBar extends HTMLElement {
}
});
// Drag-to-connect: mousedown starts a flow drag (skip if wiring)
plane.addEventListener("mousedown", (e) => {
if ((e as MouseEvent).button !== 0) return;
// Drag-to-connect: pointerdown starts a flow drag (skip if wiring)
plane.addEventListener("pointerdown", (e) => {
if ((e as PointerEvent).button !== 0) return;
if (this.#wiringActive) return;
e.stopPropagation(); // prevent orbit
this.#flowDragSource = layerId;
@ -1195,20 +1195,25 @@ export class RStackTabBar extends HTMLElement {
// 3D scene: orbit controls (drag on empty space to rotate)
const sceneContainer = this.#shadow.getElementById("stack-3d");
if (sceneContainer) {
sceneContainer.addEventListener("mousedown", (e) => {
let orbitPointerId: number | null = null;
sceneContainer.addEventListener("pointerdown", (e) => {
const pe = e as PointerEvent;
// Only orbit on left-click on empty space (not on layer planes)
if ((e as MouseEvent).button !== 0) return;
if (pe.button !== 0) return;
if ((e.target as HTMLElement).closest(".layer-plane")) return;
this.#orbitDragging = true;
this.#orbitLastX = (e as MouseEvent).clientX;
this.#orbitLastY = (e as MouseEvent).clientY;
this.#orbitLastX = pe.clientX;
this.#orbitLastY = pe.clientY;
orbitPointerId = pe.pointerId;
sceneContainer.setPointerCapture(pe.pointerId);
sceneContainer.style.cursor = "grabbing";
});
// Collect all layer planes + their rects for drag target detection
const layerPlanes = [...this.#shadow.querySelectorAll<HTMLElement>(".layer-plane")];
const onMouseMove = (e: MouseEvent) => {
const onPointerMove = (e: PointerEvent) => {
if (this.#orbitDragging) {
const dx = e.clientX - this.#orbitLastX;
const dy = e.clientY - this.#orbitLastY;
@ -1250,9 +1255,13 @@ export class RStackTabBar extends HTMLElement {
}
};
const onMouseUp = () => {
const onPointerUp = (e: PointerEvent) => {
if (this.#orbitDragging) {
this.#orbitDragging = false;
if (orbitPointerId !== null) {
try { sceneContainer.releasePointerCapture(orbitPointerId); } catch {}
orbitPointerId = null;
}
sceneContainer.style.cursor = "";
}
// Complete flow drag
@ -1267,11 +1276,11 @@ export class RStackTabBar extends HTMLElement {
};
// Attach to document for drag continuity (cleaned up on re-render)
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
this.#docCleanup = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
};
// Scroll to zoom
@ -1894,6 +1903,7 @@ const STYLES = `
align-items: center;
justify-content: center;
cursor: grab;
touch-action: none;
user-select: none;
overflow: visible;
}

View File

@ -307,7 +307,7 @@ export class TabCache {
if (appEl) {
// Remove duplicate shell chrome that shouldn't be in tab panes
appEl.querySelectorAll(
".rstack-tab-row, rstack-tab-bar, rstack-space-settings, rstack-history-panel",
".rstack-tab-row, rstack-tab-bar, rstack-history-panel",
).forEach((el) => el.remove());
body = appEl.innerHTML;
} else if (iframeWrap) {

View File

@ -1916,10 +1916,7 @@
<button class="rstack-header__history-btn" id="history-btn" title="History"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></button>
<rstack-history-panel type="canvas"></rstack-history-panel>
</div>
<div class="rstack-header__dropdown-wrap">
<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<rstack-space-settings space="" module-id="rspace"></rstack-space-settings>
</div>
<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
@ -2492,7 +2489,7 @@
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";
import { RStackTabBar } from "@shared/components/rstack-tab-bar";
import { RStackMi } from "@shared/components/rstack-mi";
import { RStackSpaceSettings } from "@shared/components/rstack-space-settings";
import { RStackHistoryPanel } from "@shared/components/rstack-history-panel";
import { RStackCommentBell } from "@shared/components/rstack-comment-bell";
import { rspaceNavUrl } from "@shared/url-helpers";
@ -2508,17 +2505,19 @@
RStackSpaceSwitcher.define();
RStackTabBar.define();
RStackMi.define();
RStackSpaceSettings.define();
RStackHistoryPanel.define();
RStackCommentBell.define();
// ── Settings & history panel toggle (same as shell.ts) ──
// ── Settings & history panel toggle ──
document.getElementById("settings-btn")?.addEventListener("click", () => {
document.querySelector("rstack-history-panel")?.close();
document.querySelector("rstack-space-settings")?.toggle();
const sw = document.querySelector("rstack-space-switcher");
const slug = sw?.getAttribute("current") || "";
const name = sw?.getAttribute("name") || slug;
if (sw && slug) sw.openSettingsModal(slug, name);
});
document.getElementById("history-btn")?.addEventListener("click", () => {
document.querySelector("rstack-space-settings")?.close();
document.querySelector("rstack-history-panel")?.toggle();
});
@ -2820,7 +2819,6 @@
if (tabBar) {
tabBar.setAttribute("space", communitySlug);
document.querySelector("rstack-collab-overlay")?.setAttribute("space", communitySlug);
document.querySelector("rstack-space-settings")?.setAttribute("space", communitySlug);
// Helper: look up a module's display name
function getModuleLabel(id) {

View File

@ -13,7 +13,7 @@ import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
import { RStackMi } from "../shared/components/rstack-mi";
import { RStackSpaceSettings } from "../shared/components/rstack-space-settings";
import { RStackModuleSetup } from "../shared/components/rstack-module-setup";
import { RStackHistoryPanel } from "../shared/components/rstack-history-panel";
import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator";
@ -40,7 +40,7 @@ RStackAppSwitcher.define();
RStackSpaceSwitcher.define();
RStackTabBar.define();
RStackMi.define();
RStackSpaceSettings.define();
RStackModuleSetup.define();
RStackHistoryPanel.define();
RStackOfflineIndicator.define();