From 73cc1d1cc46d2c37118116cc19ce5a828df4ebb8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 12:04:27 -0700 Subject: [PATCH] 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 --- server/community-store.ts | 3 + server/spaces.ts | 53 +- shared/components/rstack-space-switcher.ts | 707 ++++++++++++++++++++- 3 files changed, 740 insertions(+), 23 deletions(-) diff --git a/server/community-store.ts b/server/community-store.ts index 69fdf5c..0d07729 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -146,6 +146,7 @@ export interface CommunityMeta { enabledModules?: string[]; // null = all enabled moduleScopeOverrides?: Record; description?: string; + discoverable?: boolean; avatar?: string; nestPolicy?: NestPolicy; connectionPolicy?: ConnectionPolicy; @@ -495,6 +496,7 @@ export function updateSpaceMeta( name?: string; visibility?: SpaceVisibility; description?: string; + discoverable?: boolean; enabledModules?: string[]; moduleScopeOverrides?: Record; moduleSettings?: Record>; @@ -507,6 +509,7 @@ export function updateSpaceMeta( if (fields.name !== undefined) d.meta.name = fields.name; if (fields.visibility !== undefined) d.meta.visibility = fields.visibility; 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.moduleScopeOverrides !== undefined) d.meta.moduleScopeOverrides = fields.moduleScopeOverrides; if (fields.moduleSettings !== undefined) d.meta.moduleSettings = fields.moduleSettings; diff --git a/server/spaces.ts b/server/spaces.ts index 0ffb41c..31e280b 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -255,7 +255,8 @@ spaces.get("/", async (c) => { // Determine accessibility const isPublicSpace = vis === "public"; const isPermissioned = vis === "permissioned"; - const accessible = isPublicSpace || isOwner || isMember || (isPermissioned && !!claims); + const accessible = isPublicSpace || isOwner || isMember; + if (!accessible) continue; // For unauthenticated: only show demo if (!claims && slug !== "demo") continue; @@ -705,7 +706,7 @@ spaces.patch("/:slug", async (c) => { 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) { 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); return c.json({ @@ -722,6 +724,7 @@ spaces.patch("/:slug", async (c) => { name: updated?.meta.name, visibility: updated?.meta.visibility, 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 }); }); +// ── 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 ── spaces.get("/:slug/invites", async (c) => { diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index a09f656..963e323 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -383,26 +383,9 @@ export class RStackSpaceSwitcher extends HTMLElement { const slug = toSlug(name); const vis = (menu.querySelector('input[name="create-vis"]:checked') as HTMLInputElement)?.value || "public"; - submitBtn.disabled = true; - status.textContent = "Creating..."; - - 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; - } + // Close menu and open the Create Space modal + menu.classList.remove("open"); + this.#showCreateSpaceModal(name, slug, vis); }); } @@ -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 = ` + +
+ +

Create Space

+ +
+
+ + + + +
+ rspace.online/ + +
+
+ + + +
+ +
+ +
+ + + +
+ +
+
+ +
+ +
+
+ +
+
+ + +
+
+ You (owner) +
+
+ +
+ +
+ + +
+
A shareable invite link will be generated when you create the space.
+
+ + +
+ `; + + 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('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; + 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 = `
Invite ${q} by email
`; + 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 = `
Invite ${q} by email
`; + } else { + searchDropdown.innerHTML = `
No users found
`; + } + } 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 `
+ ${u.displayName || u.username} + @${u.username} + ${isSelf ? 'You (owner)' : ''} + ${alreadyAdded ? 'Added' : ''} +
`; + }).join(""); + + // Add email option at the bottom if input has @ and . + if (q.includes("@") && q.includes(".")) { + searchDropdown.innerHTML += `
Invite ${q} by email
`; + } + } + 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 = 'You (owner)'; + for (const m of this.#pendingMembers) { + html += `@${m.username} ${m.role} `; + } + for (const e of this.#pendingEmailInvites) { + html += ``; + } + 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 | 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 { return getModule(); } @@ -1459,3 +1922,207 @@ input:checked + .toggle-slider:before { transform: translateX(16px); } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @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); } } +`;