diff --git a/server/community-store.ts b/server/community-store.ts index 8633dd9..261d234 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -1,4 +1,4 @@ -import { mkdir, readdir } from "node:fs/promises"; +import { mkdir, readdir, unlink } from "node:fs/promises"; import * as Automerge from "@automerge/automerge"; const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities"; @@ -368,6 +368,52 @@ export async function createCommunity( return doc; } +/** + * Delete a community/space — removes from memory and disk + */ +export async function deleteCommunity(slug: string): Promise { + // Cancel any pending debounce save timer + const timer = saveTimers.get(slug); + if (timer) { + clearTimeout(timer); + saveTimers.delete(slug); + } + + // Remove from in-memory cache + communities.delete(slug); + + // Remove all peer sync states for this slug + peerSyncStates.delete(slug); + + // Delete files from disk + const automerge = `${STORAGE_DIR}/${slug}.automerge`; + const json = `${STORAGE_DIR}/${slug}.json`; + try { await unlink(automerge); } catch { /* file may not exist */ } + try { await unlink(json); } catch { /* file may not exist */ } + + console.log(`[Store] Deleted community ${slug}`); +} + +/** + * Update space metadata (name, visibility, description) + */ +export function updateSpaceMeta( + slug: string, + fields: { name?: string; visibility?: SpaceVisibility; description?: string }, +): boolean { + const doc = communities.get(slug); + if (!doc) return false; + + const newDoc = Automerge.change(doc, `Update space meta`, (d) => { + 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; + }); + communities.set(slug, newDoc); + saveCommunity(slug); + return true; +} + /** * Check if community exists */ diff --git a/server/spaces.ts b/server/spaces.ts index 809db99..4a54703 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -11,6 +11,7 @@ import { stat } from "node:fs/promises"; import { communityExists, createCommunity, + deleteCommunity, loadCommunity, getDocumentData, listCommunities, @@ -19,10 +20,12 @@ import { removeNestedSpace, getNestPolicy, updateNestPolicy, + updateSpaceMeta, capPermissions, findNestedIn, setEncryption, setMember, + removeMember, DEFAULT_COMMUNITY_NEST_POLICY, } from "./community-store"; import type { @@ -219,6 +222,212 @@ spaces.get("/:slug", async (c) => { }); }); +// ── Delete a space (owner only) ── + +spaces.delete("/:slug", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + // Protect core spaces + if (slug === "demo" || slug === "commonshub") { + return c.json({ error: "Cannot delete this space" }, 403); + } + + await loadCommunity(slug); + const data = getDocumentData(slug); + if (!data) return c.json({ error: "Space not found" }, 404); + + // Must be owner + if (data.meta.ownerDID !== claims.sub) { + return c.json({ error: "Only the space owner can delete a space" }, 403); + } + + // Notify all modules about deletion + for (const mod of getAllModules()) { + if (mod.onSpaceDelete) { + try { await mod.onSpaceDelete(slug); } catch (e) { + console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e); + } + } + } + + // Clean up in-memory request maps + for (const [id, req] of accessRequests) { + if (req.spaceSlug === slug) accessRequests.delete(id); + } + for (const [id, req] of nestRequests) { + if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id); + } + + await deleteCommunity(slug); + + return c.json({ ok: true, message: `Space "${slug}" deleted` }); +}); + +// ── Update space metadata (owner/admin) ── + +spaces.patch("/:slug", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(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 member = data.members?.[claims.sub]; + const isOwner = data.meta.ownerDID === claims.sub; + if (!isOwner && member?.role !== "admin") { + return c.json({ error: "Admin access required" }, 403); + } + + const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string }>(); + + if (body.visibility) { + const valid: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"]; + if (!valid.includes(body.visibility)) { + return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400); + } + } + + updateSpaceMeta(slug, body); + const updated = getDocumentData(slug); + + return c.json({ + ok: true, + name: updated?.meta.name, + visibility: updated?.meta.visibility, + description: updated?.meta.description, + }); +}); + +// ── List members of a space (owner/member) ── + +spaces.get("/:slug/members", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(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 memberEntry = data.members?.[claims.sub]; + if (!isOwner && !memberEntry) { + return c.json({ error: "Access denied" }, 403); + } + + const members = Object.values(data.members || {}).map((m) => ({ + ...m, + isOwner: m.did === data.meta.ownerDID, + })); + + return c.json({ members }); +}); + +// ── Change a member's role (owner/admin) ── + +spaces.patch("/:slug/members/:did", async (c) => { + const slug = c.req.param("slug"); + const did = decodeURIComponent(c.req.param("did")); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(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); + } + + // Cannot change the owner's role + if (did === data.meta.ownerDID) { + return c.json({ error: "Cannot change the owner's role" }, 403); + } + + const body = await c.req.json<{ role: "viewer" | "participant" | "moderator" | "admin" }>(); + const validRoles = ["viewer", "participant", "moderator", "admin"]; + if (!validRoles.includes(body.role)) { + return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400); + } + + setMember(slug, did, body.role); + return c.json({ ok: true, did, role: body.role }); +}); + +// ── Remove a member (owner/admin) ── + +spaces.delete("/:slug/members/:did", async (c) => { + const slug = c.req.param("slug"); + const did = decodeURIComponent(c.req.param("did")); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(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); + } + + // Cannot remove the owner + if (did === data.meta.ownerDID) { + return c.json({ error: "Cannot remove the space owner" }, 403); + } + + removeMember(slug, did); + return c.json({ ok: true }); +}); + +// ── Get access requests for a specific space (owner/admin) ── + +spaces.get("/:slug/access-requests", async (c) => { + const slug = c.req.param("slug"); + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims: EncryptIDClaims; + try { claims = await verifyEncryptIDToken(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 member = data.members?.[claims.sub]; + if (!isOwner && member?.role !== "admin") { + return c.json({ error: "Admin access required" }, 403); + } + + const requests = Array.from(accessRequests.values()) + .filter((r) => r.spaceSlug === slug); + + return c.json({ requests }); +}); + // ── Admin: list ALL spaces with detailed stats ── spaces.get("/admin", async (c) => { diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 6dd9e33..14e94be 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -143,13 +143,15 @@ export class RStackSpaceSwitcher extends HTMLElement { html += mySpaces .map((s) => { const vis = this.#visibilityInfo(s); + const canEdit = s.role === "owner" || s.role === "admin"; return ` - - ${s.icon || "🌐"} - ${s.name} - ${vis.label} - `; +
+ + ${s.icon || "🌐"} + ${s.name} + ${vis.label} + ${canEdit ? `` : ""} +
`; }) .join(""); } @@ -207,6 +209,16 @@ export class RStackSpaceSwitcher extends HTMLElement { this.#showRequestAccessModal(el.dataset.slug!, el.dataset.name!); }); }); + + // Attach Edit Space gear button listeners + menu.querySelectorAll(".item-gear").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + const el = btn as HTMLElement; + this.#showEditSpaceModal(el.dataset.editSlug!, el.dataset.editName!); + }); + }); } #showRequestAccessModal(slug: string, spaceName: string) { @@ -277,6 +289,301 @@ export class RStackSpaceSwitcher extends HTMLElement { document.body.appendChild(overlay); } + #showEditSpaceModal(slug: string, spaceName: string) { + if (document.querySelector(".rstack-auth-overlay")) return; + + const overlay = document.createElement("div"); + overlay.className = "rstack-auth-overlay"; + overlay.innerHTML = ` + +
+ +

Edit Space

+
+ + + +
+ +
+ + + + + + +
+ +
+
+
+
Danger Zone
+ +
+
+ + + + +
+ `; + + const close = () => overlay.remove(); + overlay.querySelectorAll('[data-action="close"]').forEach((el) => + el.addEventListener("click", close) + ); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + + // Tab switching + overlay.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + overlay.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + overlay.querySelectorAll(".tab-panel").forEach((p) => p.classList.add("hidden")); + tab.classList.add("active"); + const panel = overlay.querySelector(`#panel-${(tab as HTMLElement).dataset.tab}`); + panel?.classList.remove("hidden"); + + // Lazy-load content + if ((tab as HTMLElement).dataset.tab === "members") this.#loadMembers(overlay, slug); + if ((tab as HTMLElement).dataset.tab === "invitations") this.#loadInvitations(overlay, slug); + }); + }); + + // Load current settings + this.#loadSpaceSettings(overlay, slug); + + // Save handler + overlay.querySelector("#es-save")?.addEventListener("click", () => this.#saveSpaceSettings(overlay, slug)); + + // Delete handler + overlay.querySelector("#es-delete")?.addEventListener("click", () => this.#deleteSpace(overlay, slug, spaceName)); + + document.body.appendChild(overlay); + } + + async #loadSpaceSettings(overlay: HTMLElement, slug: string) { + try { + const token = getAccessToken(); + const res = await fetch(`/api/spaces/${slug}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) return; + const data = await res.json(); + const vis = overlay.querySelector("#es-visibility") as HTMLSelectElement; + if (vis && data.visibility) vis.value = data.visibility; + } catch { /* ignore */ } + } + + async #saveSpaceSettings(overlay: HTMLElement, slug: string) { + const statusEl = overlay.querySelector("#es-status") as HTMLElement; + const name = (overlay.querySelector("#es-name") as HTMLInputElement).value.trim(); + const visibility = (overlay.querySelector("#es-visibility") as HTMLSelectElement).value; + const description = (overlay.querySelector("#es-description") as HTMLTextAreaElement).value.trim(); + + if (!name) { statusEl.textContent = "Name is required"; statusEl.className = "status error"; return; } + + statusEl.textContent = "Saving..."; + statusEl.className = "status"; + + try { + const token = getAccessToken(); + const res = await fetch(`/api/spaces/${slug}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ name, visibility, description }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to save"); + + statusEl.textContent = "Saved!"; + statusEl.className = "status success"; + + // Update switcher display + this.#loaded = false; + this.setAttribute("name", name); + + setTimeout(() => { statusEl.textContent = ""; }, 2000); + } catch (err: any) { + statusEl.textContent = err.message || "Failed to save"; + statusEl.className = "status error"; + } + } + + async #deleteSpace(overlay: HTMLElement, slug: string, spaceName: string) { + if (!confirm(`Permanently delete "${spaceName}"? This cannot be undone.`)) return; + + const statusEl = overlay.querySelector("#es-status") as HTMLElement; + statusEl.textContent = "Deleting..."; + statusEl.className = "status"; + + try { + const token = getAccessToken(); + const res = await fetch(`/api/spaces/${slug}`, { + method: "DELETE", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to delete"); + + overlay.remove(); + // Redirect to demo space + window.location.href = "/demo/canvas"; + } catch (err: any) { + statusEl.textContent = err.message || "Failed to delete"; + statusEl.className = "status error"; + } + } + + async #loadMembers(overlay: HTMLElement, slug: string) { + const container = overlay.querySelector("#es-members-list") as HTMLElement; + try { + const token = getAccessToken(); + const res = await fetch(`/api/spaces/${slug}/members`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { container.innerHTML = `
Failed to load members
`; return; } + const data = await res.json(); + const members: Array<{ did: string; role: string; displayName?: string; isOwner?: boolean }> = data.members || []; + + if (members.length === 0) { + container.innerHTML = `
No members
`; + return; + } + + container.innerHTML = members.map((m) => { + const displayName = m.displayName || m.did.slice(0, 20) + "..."; + const roleOptions = ["viewer", "participant", "moderator", "admin"] + .map((r) => ``) + .join(""); + return ` +
+ ${displayName} + ${m.isOwner + ? `owner` + : ` + ` + } +
`; + }).join(""); + + // Role change handlers + container.querySelectorAll(".role-select").forEach((sel) => { + sel.addEventListener("change", async () => { + const el = sel as HTMLSelectElement; + const did = el.dataset.did!; + const role = el.value; + const token = getAccessToken(); + try { + const res = await fetch(`/api/spaces/${slug}/members/${encodeURIComponent(did)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ role }), + }); + if (!res.ok) { const d = await res.json(); alert(d.error || "Failed"); } + } catch { alert("Failed to update role"); } + }); + }); + + // Remove member handlers + container.querySelectorAll(".member-remove").forEach((btn) => { + btn.addEventListener("click", async () => { + const did = (btn as HTMLElement).dataset.did!; + if (!confirm("Remove this member?")) return; + const token = getAccessToken(); + try { + const res = await fetch(`/api/spaces/${slug}/members/${encodeURIComponent(did)}`, { + method: "DELETE", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { const d = await res.json(); alert(d.error || "Failed"); return; } + this.#loadMembers(overlay, slug); + } catch { alert("Failed to remove member"); } + }); + }); + } catch { + container.innerHTML = `
Failed to load members
`; + } + } + + async #loadInvitations(overlay: HTMLElement, slug: string) { + const container = overlay.querySelector("#es-invitations-list") as HTMLElement; + try { + const token = getAccessToken(); + const res = await fetch(`/api/spaces/${slug}/access-requests`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { container.innerHTML = `
Failed to load
`; return; } + const data = await res.json(); + const requests: Array<{ id: string; requesterUsername: string; message?: string; status: string; createdAt: number }> = data.requests || []; + + if (requests.length === 0) { + container.innerHTML = `
No access requests
`; + return; + } + + const pending = requests.filter((r) => r.status === "pending"); + const resolved = requests.filter((r) => r.status !== "pending"); + + let html = ""; + if (pending.length > 0) { + html += pending.map((r) => ` +
+
+ ${r.requesterUsername} + ${r.message ? `${r.message.replace(/` : ""} +
+
+ + +
+
`).join(""); + } + if (resolved.length > 0) { + html += `
Resolved
`; + html += resolved.map((r) => ` +
+ ${r.requesterUsername} + ${r.status} +
`).join(""); + } + + container.innerHTML = html; + + // Approve/Deny handlers + container.querySelectorAll("[data-req-id]").forEach((btn) => { + btn.addEventListener("click", async () => { + const el = btn as HTMLElement; + const reqId = el.dataset.reqId!; + const action = el.dataset.action as "approve" | "deny"; + const token = getAccessToken(); + try { + const res = await fetch(`/api/spaces/${slug}/access-requests/${reqId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ action }), + }); + if (!res.ok) { const d = await res.json(); alert(d.error || "Failed"); return; } + this.#loadInvitations(overlay, slug); + } catch { alert("Failed to process request"); } + }); + }); + } catch { + container.innerHTML = `
Failed to load invitations
`; + } + } + #getCurrentModule(): string { return getModule(); } @@ -351,6 +658,30 @@ const STYLES = ` :host-context([data-theme="light"]) .item-vis.vis-private { background: #fee2e2; color: #dc2626; } :host-context([data-theme="light"]) .item-vis.vis-permissioned { background: #fef3c7; color: #d97706; } +/* Item row: wraps link + gear icon */ +.item-row { + display: flex; align-items: center; border-left: 3px solid transparent; + transition: background 0.12s; +} +.item-row .item { + flex: 1; border-left: none; +} +:host-context([data-theme="light"]) .item-row:hover { background: #f1f5f9; } +:host-context([data-theme="light"]) .item-row.active { background: #e0f2fe; } +:host-context([data-theme="dark"]) .item-row:hover { background: rgba(255,255,255,0.05); } +:host-context([data-theme="dark"]) .item-row.active { background: rgba(6,182,212,0.1); } +.item-row.vis-public { border-left-color: #34d399; } +.item-row.vis-private { border-left-color: #f87171; } +.item-row.vis-permissioned { border-left-color: #fbbf24; } + +.item-gear { + background: none; border: none; cursor: pointer; padding: 6px 10px; + font-size: 1rem; opacity: 0.4; transition: opacity 0.15s; flex-shrink: 0; +} +.item-gear:hover { opacity: 1; } +:host-context([data-theme="light"]) .item-gear { color: #374151; } +:host-context([data-theme="dark"]) .item-gear { color: #e2e8f0; } + .item--create { font-size: 0.85rem; font-weight: 600; color: #06b6d4 !important; border-left-color: transparent !important; @@ -439,3 +770,125 @@ const REQUEST_MODAL_STYLES = ` @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @keyframes spin { to { transform: rotate(360deg); } } `; + +const EDIT_SPACE_MODAL_STYLES = ` +.rstack-auth-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); display: flex; align-items: center; + justify-content: center; z-index: 10000; animation: fadeIn 0.2s; +} +.edit-modal { + background: #1e293b; border: 1px solid rgba(255,255,255,0.1); + border-radius: 16px; padding: 2rem; max-width: 520px; width: 92%; + color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4); + animation: slideUp 0.3s; position: relative; +} +.edit-modal h2 { + font-size: 1.5rem; margin: 0 0 1rem; + background: linear-gradient(135deg, #06b6d4, #7c3aed); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; +} +.close-btn { + position: absolute; top: 12px; right: 16px; + background: none; border: none; color: #64748b; font-size: 1.5rem; + cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px; +} +.close-btn:hover { color: white; background: rgba(255,255,255,0.1); } + +/* Tabs */ +.tabs { display: flex; gap: 4px; margin-bottom: 1.2rem; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 0; } +.tab { + padding: 8px 16px; border: none; background: none; color: #94a3b8; + font-size: 0.85rem; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; + transition: all 0.15s; border-radius: 6px 6px 0 0; +} +.tab:hover { color: #e2e8f0; background: rgba(255,255,255,0.05); } +.tab.active { color: #06b6d4; border-bottom-color: #06b6d4; } + +.tab-panel { } +.tab-panel.hidden { display: none; } + +/* Form fields */ +.field-label { display: block; font-size: 0.75rem; font-weight: 600; color: #94a3b8; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; } +.input { + width: 100%; padding: 10px 14px; border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); + color: white; font-size: 0.9rem; margin-bottom: 0.75rem; outline: none; + transition: border-color 0.2s; box-sizing: border-box; font-family: inherit; +} +.input:focus { border-color: #06b6d4; } +select.input { appearance: auto; } + +.actions { display: flex; gap: 12px; margin-top: 0.5rem; } +.btn { + padding: 10px 20px; border-radius: 8px; border: none; + font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; +} +.btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; flex: 1; } +.btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } +.btn--danger { background: #dc2626; color: white; flex: 1; } +.btn--danger:hover { background: #b91c1c; } + +.status { font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; text-align: center; } +.status.success { color: #34d399; } +.status.error { color: #ef4444; } + +/* Danger zone */ +.danger-zone { + margin-top: 1.5rem; padding-top: 1rem; + border-top: 1px solid rgba(239,68,68,0.3); +} +.danger-label { font-size: 0.75rem; font-weight: 600; color: #ef4444; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } + +/* Members list */ +.member-list { max-height: 320px; overflow-y: auto; } +.member-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.06); +} +.member-item:last-child { border-bottom: none; } +.member-name { flex: 1; font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.role-badge { + font-size: 0.7rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; +} +.role-owner { background: rgba(251,191,36,0.15); color: #fbbf24; } +.role-select { + padding: 4px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.05); color: white; font-size: 0.8rem; outline: none; +} +.member-remove { + background: none; border: none; color: #64748b; font-size: 1.2rem; + cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1; +} +.member-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); } +.member-owner { opacity: 0.7; } + +/* Invitations */ +.invitation-list { max-height: 320px; overflow-y: auto; } +.invite-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.06); +} +.invite-item:last-child { border-bottom: none; } +.invite-info { flex: 1; min-width: 0; } +.invite-name { font-size: 0.875rem; font-weight: 500; display: block; } +.invite-msg { font-size: 0.8rem; color: #64748b; display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.invite-actions { display: flex; gap: 6px; flex-shrink: 0; } +.btn-sm { + padding: 4px 10px; border-radius: 6px; border: none; + font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; +} +.btn-sm--approve { background: #059669; color: white; } +.btn-sm--approve:hover { opacity: 0.85; } +.btn-sm--deny { background: rgba(239,68,68,0.15); color: #f87171; } +.btn-sm--deny:hover { background: rgba(239,68,68,0.25); } +.invite-resolved { opacity: 0.4; } +.invite-status { font-size: 0.75rem; padding: 3px 8px; border-radius: 6px; font-weight: 600; margin-left: auto; } +.invite-status--approved { background: rgba(52,211,153,0.15); color: #34d399; } +.invite-status--denied { background: rgba(239,68,68,0.15); color: #f87171; } + +.loading { padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8; } + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +`;