feat(spaces): add Create Space modal with member invites and invite links

Adds an intermediate modal when creating a space: name/slug editing with
availability check, description, visibility radio cards, discoverable
toggle, member search with @username lookup and email invites, and a
shareable invite link generated post-creation.

Server: adds discoverable field to CommunityMeta, extends PATCH /:slug,
adds POST /:slug/invites for generic invite token creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 12:04:27 -07:00
parent 460c68ddbf
commit 73cc1d1cc4
3 changed files with 740 additions and 23 deletions

View File

@ -146,6 +146,7 @@ export interface CommunityMeta {
enabledModules?: string[]; // null = all enabled enabledModules?: string[]; // null = all enabled
moduleScopeOverrides?: Record<string, 'space' | 'global'>; moduleScopeOverrides?: Record<string, 'space' | 'global'>;
description?: string; description?: string;
discoverable?: boolean;
avatar?: string; avatar?: string;
nestPolicy?: NestPolicy; nestPolicy?: NestPolicy;
connectionPolicy?: ConnectionPolicy; connectionPolicy?: ConnectionPolicy;
@ -495,6 +496,7 @@ export function updateSpaceMeta(
name?: string; name?: string;
visibility?: SpaceVisibility; visibility?: SpaceVisibility;
description?: string; description?: string;
discoverable?: boolean;
enabledModules?: string[]; enabledModules?: string[];
moduleScopeOverrides?: Record<string, 'space' | 'global'>; moduleScopeOverrides?: Record<string, 'space' | 'global'>;
moduleSettings?: Record<string, Record<string, string | boolean>>; moduleSettings?: Record<string, Record<string, string | boolean>>;
@ -507,6 +509,7 @@ export function updateSpaceMeta(
if (fields.name !== undefined) d.meta.name = fields.name; if (fields.name !== undefined) d.meta.name = fields.name;
if (fields.visibility !== undefined) d.meta.visibility = fields.visibility; if (fields.visibility !== undefined) d.meta.visibility = fields.visibility;
if (fields.description !== undefined) d.meta.description = fields.description; if (fields.description !== undefined) d.meta.description = fields.description;
if (fields.discoverable !== undefined) d.meta.discoverable = fields.discoverable;
if (fields.enabledModules !== undefined) d.meta.enabledModules = fields.enabledModules; if (fields.enabledModules !== undefined) d.meta.enabledModules = fields.enabledModules;
if (fields.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides; if (fields.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides;
if (fields.moduleSettings !== undefined) d.meta.moduleSettings = fields.moduleSettings; if (fields.moduleSettings !== undefined) d.meta.moduleSettings = fields.moduleSettings;

View File

@ -255,7 +255,8 @@ spaces.get("/", async (c) => {
// Determine accessibility // Determine accessibility
const isPublicSpace = vis === "public"; const isPublicSpace = vis === "public";
const isPermissioned = vis === "permissioned"; const isPermissioned = vis === "permissioned";
const accessible = isPublicSpace || isOwner || isMember || (isPermissioned && !!claims); const accessible = isPublicSpace || isOwner || isMember;
if (!accessible) continue;
// For unauthenticated: only show demo // For unauthenticated: only show demo
if (!claims && slug !== "demo") continue; if (!claims && slug !== "demo") continue;
@ -705,7 +706,7 @@ spaces.patch("/:slug", async (c) => {
return c.json({ error: "Admin access required" }, 403); return c.json({ error: "Admin access required" }, 403);
} }
const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string }>(); const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string; discoverable?: boolean }>();
if (body.visibility) { if (body.visibility) {
const valid: SpaceVisibility[] = ["public", "permissioned", "private"]; const valid: SpaceVisibility[] = ["public", "permissioned", "private"];
@ -714,7 +715,8 @@ spaces.patch("/:slug", async (c) => {
} }
} }
updateSpaceMeta(slug, body); const { name, visibility, description, discoverable } = body;
updateSpaceMeta(slug, { name, visibility, description, discoverable });
const updated = getDocumentData(slug); const updated = getDocumentData(slug);
return c.json({ return c.json({
@ -722,6 +724,7 @@ spaces.patch("/:slug", async (c) => {
name: updated?.meta.name, name: updated?.meta.name,
visibility: updated?.meta.visibility, visibility: updated?.meta.visibility,
description: updated?.meta.description, description: updated?.meta.description,
discoverable: updated?.meta.discoverable,
}); });
}); });
@ -2348,6 +2351,50 @@ spaces.post("/:slug/invite/accept", async (c) => {
return c.json({ ok: true, spaceSlug: result.spaceSlug, role: result.role }); return c.json({ ok: true, spaceSlug: result.spaceSlug, role: result.role });
}); });
// ── Create generic invite link (no email required) ──
spaces.post("/:slug/invites", async (c) => {
const { slug } = c.req.param();
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims: EncryptIDClaims;
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const isOwner = data.meta.ownerDID === claims.sub;
const callerMember = data.members?.[claims.sub];
if (!isOwner && callerMember?.role !== "admin") {
return c.json({ error: "Admin access required" }, 403);
}
const body = await c.req.json<{ role?: string; maxUses?: number; expiresInDays?: number }>().catch(() => ({}));
const role = (body as any).role || "member";
const maxUses = (body as any).maxUses || 0; // 0 = unlimited
const expiresInDays = (body as any).expiresInDays || 7;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ role, maxUses, expiresInDays }),
});
const result = await res.json();
if (!res.ok) {
return c.json(result as any, res.status as any);
}
return c.json(result as any, 201);
} catch {
return c.json({ error: "Failed to create invite" }, 500);
}
});
// ── List invites for settings panel ── // ── List invites for settings panel ──
spaces.get("/:slug/invites", async (c) => { spaces.get("/:slug/invites", async (c) => {

View File

@ -383,26 +383,9 @@ export class RStackSpaceSwitcher extends HTMLElement {
const slug = toSlug(name); const slug = toSlug(name);
const vis = (menu.querySelector('input[name="create-vis"]:checked') as HTMLInputElement)?.value || "public"; const vis = (menu.querySelector('input[name="create-vis"]:checked') as HTMLInputElement)?.value || "public";
submitBtn.disabled = true; // Close menu and open the Create Space modal
status.textContent = "Creating..."; menu.classList.remove("open");
this.#showCreateSpaceModal(name, slug, vis);
try {
const res = await fetch("/api/spaces", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, slug, visibility: vis }),
});
const data = await res.json();
if (res.ok && data.slug) {
window.location.href = rspaceNavUrl(data.slug, "rspace");
} else {
status.textContent = data.error || "Failed to create space";
submitBtn.disabled = false;
}
} catch {
status.textContent = "Network error";
submitBtn.disabled = false;
}
}); });
} }
@ -1064,6 +1047,486 @@ export class RStackSpaceSwitcher extends HTMLElement {
} }
} }
// ── Create Space Modal ──
#pendingMembers: Array<{ username: string; did: string; displayName: string; role: string; type: 'username' }> = [];
#pendingEmailInvites: Array<{ email: string; role: string }> = [];
#slugManuallyEdited = false;
#showCreateSpaceModal(name: string, slug: string, visibility: string) {
if (document.querySelector(".rstack-create-space-overlay")) return;
this.#pendingMembers = [];
this.#pendingEmailInvites = [];
this.#slugManuallyEdited = false;
const discoverableDefault = visibility === 'public';
const overlay = document.createElement("div");
overlay.className = "rstack-create-space-overlay";
overlay.innerHTML = `
<style>${CREATE_SPACE_MODAL_STYLES}</style>
<div class="cs-modal">
<button class="cs-close" data-action="close">&times;</button>
<h2 class="cs-title">Create Space</h2>
<div class="cs-columns">
<div class="cs-col-left">
<label class="cs-label">Space Name</label>
<input class="cs-input" id="cs-name" value="${name.replace(/"/g, "&quot;")}" maxlength="60" />
<label class="cs-label">Slug</label>
<div class="cs-slug-row">
<span class="cs-slug-prefix">rspace.online/</span>
<input class="cs-input cs-slug-input" id="cs-slug" value="${slug}" maxlength="40" />
</div>
<div class="cs-slug-status" id="cs-slug-status"></div>
<label class="cs-label">Description</label>
<textarea class="cs-input cs-textarea" id="cs-description" rows="3" placeholder="What is this space about?"></textarea>
</div>
<div class="cs-col-right">
<label class="cs-label">Visibility</label>
<div class="cs-vis-cards">
<label class="cs-vis-card ${visibility === 'private' ? 'selected' : ''}">
<input type="radio" name="cs-vis" value="private" ${visibility === 'private' ? 'checked' : ''} />
<span class="cs-vis-dot cs-vis-dot--private"></span>
<span class="cs-vis-card-title">Private</span>
<span class="cs-vis-card-desc">Invite-only</span>
</label>
<label class="cs-vis-card ${visibility === 'permissioned' ? 'selected' : ''}">
<input type="radio" name="cs-vis" value="permissioned" ${visibility === 'permissioned' ? 'checked' : ''} />
<span class="cs-vis-dot cs-vis-dot--permissioned"></span>
<span class="cs-vis-card-title">Permissioned</span>
<span class="cs-vis-card-desc">Auth required</span>
</label>
<label class="cs-vis-card ${visibility === 'public' ? 'selected' : ''}">
<input type="radio" name="cs-vis" value="public" ${visibility === 'public' ? 'checked' : ''} />
<span class="cs-vis-dot cs-vis-dot--public"></span>
<span class="cs-vis-card-title">Public</span>
<span class="cs-vis-card-desc">Anyone can view</span>
</label>
</div>
<label class="cs-discover-row ${visibility === 'private' ? 'cs-hidden' : ''}" id="cs-discover-row">
<input type="checkbox" id="cs-discoverable" ${discoverableDefault ? 'checked' : ''} />
<span>Show in Explore Spaces</span>
</label>
</div>
</div>
<div class="cs-section">
<label class="cs-label">Add Members</label>
<div class="cs-member-search-row">
<div class="cs-search-wrapper">
<input class="cs-input cs-member-input" id="cs-member-search" placeholder="@username or email" autocomplete="off" />
<div class="cs-search-dropdown" id="cs-search-dropdown"></div>
</div>
<select class="cs-role-select" id="cs-member-role">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
<button class="cs-add-btn" id="cs-add-member" disabled>Add</button>
</div>
<div class="cs-member-chips" id="cs-member-chips">
<span class="cs-chip cs-chip--owner">You (owner)</span>
</div>
</div>
<div class="cs-section">
<label class="cs-label">Invite Link</label>
<div class="cs-invite-row">
<input class="cs-input cs-invite-input" id="cs-invite-link" value="" readonly placeholder="Created after space is made..." />
<button class="cs-copy-btn" id="cs-copy-invite" disabled>Copy</button>
</div>
<div class="cs-invite-hint">A shareable invite link will be generated when you create the space.</div>
</div>
<div class="cs-footer">
<div class="cs-status" id="cs-status"></div>
<div class="cs-footer-actions">
<button class="cs-btn cs-btn--secondary" data-action="close">Cancel</button>
<button class="cs-btn cs-btn--primary" id="cs-create-btn">Create Space</button>
</div>
</div>
</div>
`;
const close = () => {
overlay.remove();
// Re-open create form with values preserved
this.#createFormOpen = true;
this.#createName = (overlay.querySelector("#cs-name") as HTMLInputElement)?.value || name;
};
overlay.querySelectorAll('[data-action="close"]').forEach(el =>
el.addEventListener("click", close)
);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
document.body.appendChild(overlay);
// ── Wire up all interactivity ──
const nameInput = overlay.querySelector("#cs-name") as HTMLInputElement;
const slugInput = overlay.querySelector("#cs-slug") as HTMLInputElement;
const slugStatus = overlay.querySelector("#cs-slug-status") as HTMLElement;
const visCards = overlay.querySelectorAll<HTMLInputElement>('input[name="cs-vis"]');
const discoverRow = overlay.querySelector("#cs-discover-row") as HTMLElement;
const discoverCheck = overlay.querySelector("#cs-discoverable") as HTMLInputElement;
const searchInput = overlay.querySelector("#cs-member-search") as HTMLInputElement;
const searchDropdown = overlay.querySelector("#cs-search-dropdown") as HTMLElement;
const roleSelect = overlay.querySelector("#cs-member-role") as HTMLSelectElement;
const addBtn = overlay.querySelector("#cs-add-member") as HTMLButtonElement;
const chipsEl = overlay.querySelector("#cs-member-chips") as HTMLElement;
const createBtn = overlay.querySelector("#cs-create-btn") as HTMLButtonElement;
const statusEl = overlay.querySelector("#cs-status") as HTMLElement;
const toSlug = (n: string) =>
n.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
// Name → slug auto-sync
nameInput.addEventListener("input", () => {
if (!this.#slugManuallyEdited) {
slugInput.value = toSlug(nameInput.value);
this.#checkSlugAvailability(slugInput.value, slugStatus, createBtn);
}
});
// Slug manual edit detection
slugInput.addEventListener("input", () => {
this.#slugManuallyEdited = true;
slugInput.value = toSlug(slugInput.value);
this.#checkSlugAvailability(slugInput.value, slugStatus, createBtn);
});
// Visibility → discoverable sync
visCards.forEach(radio => {
radio.addEventListener("change", () => {
// Update card selection styling
overlay.querySelectorAll(".cs-vis-card").forEach(c => c.classList.remove("selected"));
radio.closest(".cs-vis-card")?.classList.add("selected");
const vis = radio.value;
if (vis === 'private') {
discoverRow.classList.add("cs-hidden");
discoverCheck.checked = false;
} else {
discoverRow.classList.remove("cs-hidden");
discoverCheck.checked = vis === 'public';
}
});
});
// Member search with debounce
let searchTimer: ReturnType<typeof setTimeout>;
let selectedUser: { username: string; did: string; displayName: string } | null = null;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimer);
const q = searchInput.value.trim();
selectedUser = null;
addBtn.disabled = true;
if (q.length < 2) {
searchDropdown.innerHTML = "";
searchDropdown.classList.remove("open");
return;
}
// Check if it looks like an email
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
searchTimer = setTimeout(async () => {
if (isEmail) {
searchDropdown.innerHTML = `<div class="cs-dd-item cs-dd-email" data-email="${q.replace(/"/g, "&quot;")}">Invite <strong>${q}</strong> by email</div>`;
searchDropdown.classList.add("open");
this.#attachDropdownEmail(searchDropdown, searchInput, roleSelect, addBtn, chipsEl);
return;
}
try {
const token = getAccessToken();
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}&limit=5`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) { searchDropdown.classList.remove("open"); return; }
const data = await res.json();
const users: Array<{ username: string; did: string; displayName?: string }> = data.users || [];
if (users.length === 0) {
// Check for email-like input
if (q.includes("@") && q.includes(".")) {
searchDropdown.innerHTML = `<div class="cs-dd-item cs-dd-email" data-email="${q.replace(/"/g, "&quot;")}">Invite <strong>${q}</strong> by email</div>`;
} else {
searchDropdown.innerHTML = `<div class="cs-dd-empty">No users found</div>`;
}
} else {
const myUsername = getUsername()?.toLowerCase();
searchDropdown.innerHTML = users.map(u => {
const isSelf = u.username.toLowerCase() === myUsername;
const alreadyAdded = this.#pendingMembers.some(m => m.did === u.did);
return `<div class="cs-dd-item ${isSelf ? 'cs-dd-self' : ''} ${alreadyAdded ? 'cs-dd-added' : ''}" data-username="${u.username}" data-did="${u.did}" data-display="${(u.displayName || u.username).replace(/"/g, "&quot;")}">
<span class="cs-dd-name">${u.displayName || u.username}</span>
<span class="cs-dd-user">@${u.username}</span>
${isSelf ? '<span class="cs-dd-badge">You (owner)</span>' : ''}
${alreadyAdded ? '<span class="cs-dd-badge">Added</span>' : ''}
</div>`;
}).join("");
// Add email option at the bottom if input has @ and .
if (q.includes("@") && q.includes(".")) {
searchDropdown.innerHTML += `<div class="cs-dd-item cs-dd-email" data-email="${q.replace(/"/g, "&quot;")}">Invite <strong>${q}</strong> by email</div>`;
}
}
searchDropdown.classList.add("open");
// Attach click handlers
searchDropdown.querySelectorAll(".cs-dd-item:not(.cs-dd-self):not(.cs-dd-added):not(.cs-dd-email)").forEach(item => {
item.addEventListener("click", () => {
const el = item as HTMLElement;
selectedUser = {
username: el.dataset.username!,
did: el.dataset.did!,
displayName: el.dataset.display!,
};
searchInput.value = `@${selectedUser.username}`;
searchDropdown.classList.remove("open");
addBtn.disabled = false;
});
});
this.#attachDropdownEmail(searchDropdown, searchInput, roleSelect, addBtn, chipsEl);
} catch {
searchDropdown.classList.remove("open");
}
}, 300);
});
// Close dropdown on outside click
overlay.addEventListener("click", (e) => {
if (!(e.target as HTMLElement).closest(".cs-search-wrapper")) {
searchDropdown.classList.remove("open");
}
});
// Add member button
addBtn.addEventListener("click", () => {
if (selectedUser) {
const role = roleSelect.value;
this.#pendingMembers.push({ ...selectedUser, role, type: 'username' });
selectedUser = null;
searchInput.value = "";
addBtn.disabled = true;
this.#renderMemberChips(chipsEl);
}
});
// Create button
createBtn.addEventListener("click", () => {
this.#doCreateSpace(overlay);
});
// Initial slug check
if (slug) {
this.#checkSlugAvailability(slug, slugStatus, createBtn);
}
}
#attachDropdownEmail(dropdown: HTMLElement, searchInput: HTMLInputElement, roleSelect: HTMLSelectElement, addBtn: HTMLButtonElement, chipsEl: HTMLElement) {
dropdown.querySelectorAll(".cs-dd-email").forEach(item => {
item.addEventListener("click", () => {
const email = (item as HTMLElement).dataset.email!;
const role = roleSelect.value;
if (!this.#pendingEmailInvites.some(e => e.email === email)) {
this.#pendingEmailInvites.push({ email, role });
}
searchInput.value = "";
dropdown.classList.remove("open");
addBtn.disabled = true;
this.#renderMemberChips(chipsEl);
});
});
}
#renderMemberChips(chipsEl: HTMLElement) {
let html = '<span class="cs-chip cs-chip--owner">You (owner)</span>';
for (const m of this.#pendingMembers) {
html += `<span class="cs-chip">@${m.username} <span class="cs-chip-role">${m.role}</span> <button class="cs-chip-remove" data-type="username" data-did="${m.did}">&times;</button></span>`;
}
for (const e of this.#pendingEmailInvites) {
html += `<span class="cs-chip cs-chip--email">${e.email} <span class="cs-chip-role">${e.role}</span> <button class="cs-chip-remove" data-type="email" data-email="${e.email}">&times;</button></span>`;
}
chipsEl.innerHTML = html;
// Attach remove handlers
chipsEl.querySelectorAll(".cs-chip-remove").forEach(btn => {
btn.addEventListener("click", () => {
const el = btn as HTMLElement;
if (el.dataset.type === 'username') {
this.#pendingMembers = this.#pendingMembers.filter(m => m.did !== el.dataset.did);
} else {
this.#pendingEmailInvites = this.#pendingEmailInvites.filter(e => e.email !== el.dataset.email);
}
this.#renderMemberChips(chipsEl);
});
});
}
#slugCheckTimer: ReturnType<typeof setTimeout> | null = null;
#checkSlugAvailability(slug: string, statusEl: HTMLElement, createBtn: HTMLButtonElement) {
if (this.#slugCheckTimer) clearTimeout(this.#slugCheckTimer);
if (!slug) {
statusEl.textContent = "";
createBtn.disabled = true;
return;
}
if (!/^[a-z0-9-]+$/.test(slug)) {
statusEl.textContent = "Only lowercase letters, numbers, and hyphens";
statusEl.className = "cs-slug-status cs-slug-taken";
createBtn.disabled = true;
return;
}
statusEl.textContent = "Checking...";
statusEl.className = "cs-slug-status";
this.#slugCheckTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/spaces/${slug}`, { method: "GET" });
if (res.status === 404) {
statusEl.textContent = "Available";
statusEl.className = "cs-slug-status cs-slug-available";
createBtn.disabled = false;
} else {
statusEl.textContent = "Already taken";
statusEl.className = "cs-slug-status cs-slug-taken";
createBtn.disabled = true;
}
} catch {
statusEl.textContent = "";
createBtn.disabled = false;
}
}, 400);
}
async #doCreateSpace(overlay: HTMLElement) {
const nameInput = overlay.querySelector("#cs-name") as HTMLInputElement;
const slugInput = overlay.querySelector("#cs-slug") as HTMLInputElement;
const descInput = overlay.querySelector("#cs-description") as HTMLTextAreaElement;
const visRadio = overlay.querySelector('input[name="cs-vis"]:checked') as HTMLInputElement;
const discoverCheck = overlay.querySelector("#cs-discoverable") as HTMLInputElement;
const createBtn = overlay.querySelector("#cs-create-btn") as HTMLButtonElement;
const statusEl = overlay.querySelector("#cs-status") as HTMLElement;
const inviteLinkInput = overlay.querySelector("#cs-invite-link") as HTMLInputElement;
const copyBtn = overlay.querySelector("#cs-copy-invite") as HTMLButtonElement;
const name = nameInput.value.trim();
const slug = slugInput.value.trim();
const description = descInput.value.trim();
const visibility = visRadio?.value || "public";
const discoverable = discoverCheck?.checked ?? false;
if (!name) { statusEl.textContent = "Name is required"; statusEl.className = "cs-status cs-status--error"; return; }
if (!slug) { statusEl.textContent = "Slug is required"; statusEl.className = "cs-status cs-status--error"; return; }
const token = getAccessToken();
if (!token) { statusEl.textContent = "Please sign in first"; statusEl.className = "cs-status cs-status--error"; return; }
createBtn.disabled = true;
statusEl.textContent = "Creating space...";
statusEl.className = "cs-status";
try {
// Step 1: Create the space
const createRes = await fetch("/api/spaces", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, slug, visibility }),
});
const createData = await createRes.json();
if (!createRes.ok) {
statusEl.textContent = createData.error || "Failed to create space";
statusEl.className = "cs-status cs-status--error";
createBtn.disabled = false;
return;
}
// Step 2: Apply description + discoverable (if non-default)
if (description || discoverable) {
statusEl.textContent = "Configuring space...";
await fetch(`/api/spaces/${slug}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ description, discoverable }),
}).catch(() => {});
}
// Step 3: Add members + send email invites (best-effort, parallel)
if (this.#pendingMembers.length > 0 || this.#pendingEmailInvites.length > 0) {
statusEl.textContent = "Adding members...";
const memberPromises = this.#pendingMembers.map(m =>
fetch(`/api/spaces/${slug}/members/add`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ username: m.username, role: m.role }),
}).catch(() => {})
);
const emailPromises = this.#pendingEmailInvites.map(e =>
fetch(`/api/spaces/${slug}/invite`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ email: e.email, role: e.role }),
}).catch(() => {})
);
await Promise.all([...memberPromises, ...emailPromises]);
}
// Step 4: Generate invite link
statusEl.textContent = "Generating invite link...";
try {
const inviteRes = await fetch(`/api/spaces/${slug}/invites`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ role: "member", maxUses: 0, expiresInDays: 7 }),
});
if (inviteRes.ok) {
const inviteData = await inviteRes.json();
const inviteToken = inviteData.token || inviteData.inviteToken;
if (inviteToken) {
const inviteUrl = `https://${slug}.rspace.online/join/${inviteToken}`;
inviteLinkInput.value = inviteUrl;
copyBtn.disabled = false;
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(inviteUrl).then(() => {
copyBtn.textContent = "Copied!";
setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000);
});
});
}
}
} catch {
// invite link is best-effort
}
// Step 5: Success state
statusEl.textContent = "Space created! Redirecting...";
statusEl.className = "cs-status cs-status--success";
createBtn.textContent = "Done";
// Auto-redirect after 3 seconds
setTimeout(() => {
window.location.href = rspaceNavUrl(slug, "rspace");
}, 3000);
} catch (err: any) {
statusEl.textContent = err.message || "Something went wrong";
statusEl.className = "cs-status cs-status--error";
createBtn.disabled = false;
}
}
#getCurrentModule(): string { #getCurrentModule(): string {
return getModule(); return getModule();
} }
@ -1459,3 +1922,207 @@ input:checked + .toggle-slider:before { transform: translateX(16px); }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
`; `;
const CREATE_SPACE_MODAL_STYLES = `
.rstack-create-space-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
z-index: 10001; animation: cssFadeIn 0.2s;
}
.cs-modal {
background: var(--rs-bg-surface, #0a0a0a); border: 1px solid var(--rs-border, #262626);
border-radius: 16px; padding: 1.75rem 2rem; max-width: 640px; width: 94%;
max-height: 90vh; overflow-y: auto;
color: var(--rs-text-primary, #e5e5e5); box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
animation: cssSlideUp 0.3s; position: relative;
}
.cs-title {
font-size: 1.4rem; margin: 0 0 1.25rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.cs-close {
position: absolute; top: 12px; right: 16px;
background: none; border: none; color: var(--rs-text-muted, #525252); font-size: 1.5rem;
cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px;
}
.cs-close:hover { color: var(--rs-text-primary, #e5e5e5); background: var(--rs-border, #262626); }
/* Two-column layout */
.cs-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.25rem; }
@media (max-width: 560px) { .cs-columns { grid-template-columns: 1fr; } }
.cs-label {
display: block; font-size: 0.72rem; font-weight: 700; color: var(--rs-text-secondary, #a3a3a3);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 5px; margin-top: 0.75rem;
}
.cs-label:first-child { margin-top: 0; }
.cs-input {
width: 100%; padding: 9px 12px; border-radius: 8px;
border: 1px solid var(--rs-input-border, #404040); background: var(--rs-bg-hover, #171717);
color: var(--rs-text-primary, #e5e5e5); font-size: 0.875rem; outline: none;
transition: border-color 0.2s; box-sizing: border-box; font-family: inherit;
}
.cs-input:focus { border-color: #06b6d4; }
.cs-input::placeholder { color: var(--rs-text-muted, #525252); }
.cs-textarea { resize: vertical; min-height: 60px; }
/* Slug row */
.cs-slug-row { display: flex; align-items: center; gap: 0; }
.cs-slug-prefix {
padding: 9px 0 9px 12px; background: var(--rs-bg-hover, #171717);
border: 1px solid var(--rs-input-border, #404040); border-right: none;
border-radius: 8px 0 0 8px; font-size: 0.8rem; color: var(--rs-text-muted, #525252);
white-space: nowrap;
}
.cs-slug-input { border-radius: 0 8px 8px 0 !important; }
.cs-slug-status { font-size: 0.72rem; min-height: 1.1em; margin-top: 3px; }
.cs-slug-available { color: #34d399; }
.cs-slug-taken { color: #f87171; }
/* Visibility radio cards */
.cs-vis-cards { display: flex; flex-direction: column; gap: 6px; }
.cs-vis-card {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--rs-border, #262626);
transition: border-color 0.15s, background 0.15s;
}
.cs-vis-card:hover { background: var(--rs-bg-hover, #171717); }
.cs-vis-card.selected { border-color: #06b6d4; background: rgba(6,182,212,0.05); }
.cs-vis-card input[type="radio"] { display: none; }
.cs-vis-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.cs-vis-dot--private { background: #f87171; }
.cs-vis-dot--permissioned { background: #fbbf24; }
.cs-vis-dot--public { background: #34d399; }
.cs-vis-card-title { font-size: 0.85rem; font-weight: 600; }
.cs-vis-card-desc { font-size: 0.75rem; color: var(--rs-text-muted, #525252); margin-left: auto; }
/* Discoverable toggle */
.cs-discover-row {
display: flex; align-items: center; gap: 8px; margin-top: 0.75rem;
font-size: 0.82rem; color: var(--rs-text-secondary, #a3a3a3); cursor: pointer;
}
.cs-discover-row input[type="checkbox"] { margin: 0; accent-color: #06b6d4; }
.cs-hidden { display: none !important; }
/* Sections */
.cs-section { margin-bottom: 1rem; }
/* Member search */
.cs-member-search-row { display: flex; gap: 8px; align-items: center; }
.cs-search-wrapper { position: relative; flex: 1; }
.cs-member-input { width: 100%; }
.cs-search-dropdown {
display: none; position: absolute; top: 100%; left: 0; right: 0;
background: var(--rs-bg-surface, #0a0a0a); border: 1px solid var(--rs-border, #262626);
border-radius: 8px; margin-top: 4px; max-height: 200px; overflow-y: auto;
z-index: 100; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.cs-search-dropdown.open { display: block; }
.cs-dd-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer; transition: background 0.1s;
font-size: 0.85rem;
}
.cs-dd-item:hover { background: var(--rs-bg-hover, #171717); }
.cs-dd-name { font-weight: 500; }
.cs-dd-user { color: var(--rs-text-muted, #525252); font-size: 0.8rem; }
.cs-dd-badge {
margin-left: auto; font-size: 0.7rem; padding: 2px 6px; border-radius: 4px;
background: var(--rs-bg-hover, #171717); color: var(--rs-text-muted, #525252);
}
.cs-dd-self { opacity: 0.4; cursor: default; }
.cs-dd-self:hover { background: transparent; }
.cs-dd-added { opacity: 0.4; cursor: default; }
.cs-dd-added:hover { background: transparent; }
.cs-dd-email { color: #06b6d4; }
.cs-dd-empty { padding: 12px; text-align: center; font-size: 0.82rem; color: var(--rs-text-muted, #525252); }
.cs-role-select {
padding: 8px 10px; border-radius: 8px;
border: 1px solid var(--rs-input-border, #404040); background: var(--rs-bg-hover, #171717);
color: var(--rs-text-primary, #e5e5e5); font-size: 0.82rem; outline: none;
}
.cs-add-btn {
padding: 8px 16px; border-radius: 8px; border: none;
background: #06b6d4; color: white; font-size: 0.82rem; font-weight: 600;
cursor: pointer; transition: opacity 0.15s; white-space: nowrap;
}
.cs-add-btn:hover { opacity: 0.85; }
.cs-add-btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* Member chips */
.cs-member-chips {
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; min-height: 28px;
}
.cs-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 20px; font-size: 0.78rem;
background: var(--rs-bg-hover, #171717); border: 1px solid var(--rs-border, #262626);
color: var(--rs-text-primary, #e5e5e5);
}
.cs-chip--owner {
background: rgba(251,191,36,0.1); border-color: rgba(251,191,36,0.3); color: #fbbf24;
}
.cs-chip--email { color: #06b6d4; border-color: rgba(6,182,212,0.3); }
.cs-chip-role { font-size: 0.68rem; color: var(--rs-text-muted, #525252); }
.cs-chip-remove {
background: none; border: none; color: var(--rs-text-muted, #525252);
cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 2px;
}
.cs-chip-remove:hover { color: #f87171; }
/* Invite link row */
.cs-invite-row { display: flex; gap: 8px; }
.cs-invite-input { flex: 1; font-size: 0.8rem; color: var(--rs-text-muted, #525252); }
.cs-copy-btn {
padding: 8px 16px; border-radius: 8px; border: none;
background: var(--rs-bg-hover, #171717); border: 1px solid var(--rs-border, #262626);
color: var(--rs-text-primary, #e5e5e5); font-size: 0.82rem; font-weight: 600;
cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.cs-copy-btn:hover { background: var(--rs-border, #262626); }
.cs-copy-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.cs-invite-hint { font-size: 0.72rem; color: var(--rs-text-muted, #525252); margin-top: 4px; }
/* Footer */
.cs-footer {
margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--rs-border, #262626);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.cs-footer-actions { display: flex; gap: 10px; }
.cs-status { font-size: 0.82rem; flex: 1; }
.cs-status--error { color: #f87171; }
.cs-status--success { color: #34d399; }
.cs-btn {
padding: 10px 22px; border-radius: 8px; border: none;
font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.cs-btn--primary {
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
}
.cs-btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
.cs-btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.cs-btn--secondary {
background: var(--rs-btn-secondary-bg, #1a1a1a); color: var(--rs-text-secondary, #a3a3a3);
border: 1px solid var(--rs-border, #262626);
}
.cs-btn--secondary:hover { background: var(--rs-bg-hover, #171717); color: var(--rs-text-primary, #e5e5e5); }
@keyframes cssFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes cssSlideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
`;