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:
parent
460c68ddbf
commit
73cc1d1cc4
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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">×</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, """)}" 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, """)}">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, """)}">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, """)}">
|
||||||
|
<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, """)}">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}">×</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}">×</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); } }
|
||||||
|
`;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue