diff --git a/server/oauth/index.ts b/server/oauth/index.ts index b91e291..099c98b 100644 --- a/server/oauth/index.ts +++ b/server/oauth/index.ts @@ -6,12 +6,16 @@ * - Google (user-level, with token refresh) * - ClickUp (workspace-level, task sync) * - * Also provides GET /status?space=X to check connection status (no tokens). + * Also provides: + * - GET /status?space=X — check connection status (no tokens) + * - GET /sharing?space=X — get sharing config (which spaces each provider shares to) + * - POST /sharing — update sharing config * * Tokens are stored in Automerge docs per space via SyncServer. */ import { Hono } from 'hono'; +import * as Automerge from '@automerge/automerge'; import { notionOAuthRoutes } from './notion'; import { googleOAuthRoutes } from './google'; import { clickupOAuthRoutes } from './clickup'; @@ -82,4 +86,60 @@ oauthRouter.get('/status', (c) => { return c.json(status); }); +// ── Sharing config ── +// Stored as an Automerge doc: {userSpace}:oauth:sharing +interface SharingDoc { + meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; + sharing?: Record; // e.g. { google: { spaces: ['team-x', 'dao-y'] } } +} + +function sharingDocId(userSpace: string) { + return `${userSpace}:oauth:sharing` as const; +} + +function ensureSharingDoc(userSpace: string): SharingDoc { + if (!_syncServer) throw new Error('SyncServer not initialized'); + const docId = sharingDocId(userSpace); + let doc = _syncServer.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init oauth sharing', (d) => { + d.meta = { module: 'oauth', collection: 'sharing', version: 1, spaceSlug: userSpace, createdAt: Date.now() }; + d.sharing = {} as any; + }); + _syncServer.setDoc(docId, doc); + } + return doc; +} + +// GET /sharing?space=X — get sharing config for user's personal space +oauthRouter.get('/sharing', (c) => { + const space = c.req.query('space'); + if (!space) return c.json({ error: 'space query param required' }, 400); + if (!_syncServer) return c.json({ error: 'SyncServer not initialized' }, 500); + + const doc = _syncServer.getDoc(sharingDocId(space)); + return c.json(doc?.sharing || {}); +}); + +// POST /sharing — update sharing config +// Body: { space: string, provider: string, sharedSpaces: string[] } +oauthRouter.post('/sharing', async (c) => { + const body = await c.req.json<{ space: string; provider: string; sharedSpaces: string[] }>(); + if (!body.space || !body.provider || !Array.isArray(body.sharedSpaces)) { + return c.json({ error: 'space, provider, and sharedSpaces[] required' }, 400); + } + if (!_syncServer) return c.json({ error: 'SyncServer not initialized' }, 500); + + ensureSharingDoc(body.space); + const docId = sharingDocId(body.space); + + _syncServer.changeDoc(docId, `Update ${body.provider} sharing`, (d) => { + if (!d.sharing) d.sharing = {} as any; + if (!d.sharing![body.provider]) d.sharing![body.provider] = { spaces: [] } as any; + d.sharing![body.provider].spaces = body.sharedSpaces as any; + }); + + return c.json({ ok: true }); +}); + export { oauthRouter }; diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 4a2fb15..025b5a3 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -1163,6 +1163,13 @@ export class RStackIdentity extends HTMLElement { let addressesLoaded = false; let addressesLoading = false; + // Connections data + let connectionsLoaded = false; + let connectionsLoading = false; + let connectionsStatus: Record = {}; + let sharingConfig: Record = {}; + let userSpaces: Array<{ slug: string; name: string }> = []; + let emailStep: "input" | "verify" = "input"; let emailAddr = ""; @@ -1224,6 +1231,7 @@ export class RStackIdentity extends HTMLElement { ${renderShortcutsSection()} + ${renderConnectionsSection()}
@@ -1458,6 +1466,119 @@ export class RStackIdentity extends HTMLElement { `; }; + const CONN_PLATFORMS: Array<{ id: string; name: string; icon: string; oauthKey?: string; comingSoon?: boolean }> = [ + { id: "google", name: "Google", icon: "G", oauthKey: "google" }, + { id: "notion", name: "Notion", icon: "N", oauthKey: "notion" }, + { id: "clickup", name: "ClickUp", icon: "\u2713", oauthKey: "clickup" }, + { id: "telegram", name: "Telegram", icon: "\u2708", comingSoon: true }, + { id: "discord", name: "Discord", icon: "\uD83C\uDFAE", comingSoon: true }, + { id: "github", name: "GitHub", icon: "\uD83D\uDC19", comingSoon: true }, + { id: "slack", name: "Slack", icon: "#", comingSoon: true }, + { id: "twitter", name: "X / Twitter", icon: "X", comingSoon: true }, + { id: "bluesky", name: "Bluesky", icon: "\uD83E\uDD8B", comingSoon: true }, + { id: "linear", name: "Linear", icon: "Lin", comingSoon: true }, + ]; + + const renderConnectionsSection = () => { + const isOpen = openSection === "connections"; + const connectedCount = Object.values(connectionsStatus).filter(s => s.connected).length; + const anyConnected = connectedCount > 0; + let body = ""; + if (isOpen) { + if (connectionsLoading) { + body = ``; + } else { + const username = getUsername() || ""; + let cards = ""; + for (const p of CONN_PLATFORMS) { + const info = p.oauthKey ? connectionsStatus[p.oauthKey] : undefined; + const connected = info?.connected ?? false; + const cardClass = p.comingSoon ? "conn-card conn-card--soon" : connected ? "conn-card conn-card--active" : "conn-card"; + + let badge = ""; + let meta = ""; + let action = ""; + let sharing = ""; + + if (p.comingSoon) { + badge = `Coming Soon`; + } else if (connected) { + badge = `Connected`; + if (info?.email) meta = `
${info.email}
`; + else if (info?.workspaceName) meta = `
${info.workspaceName}
`; + else if (info?.teamName) meta = `
${info.teamName}
`; + action = ``; + + // Sharing: which spaces to share data into + const sharedSpaces = sharingConfig[p.oauthKey!]?.spaces || []; + if (userSpaces.length > 0) { + const spaceChecks = userSpaces.map(s => { + const checked = sharedSpaces.includes(s.slug) ? "checked" : ""; + return ``; + }).join(""); + sharing = `
Share to:
${spaceChecks}
`; + } + } else { + badge = `Not connected`; + action = ``; + } + + cards += ` +
+
+
${p.icon}
+
+
${p.name}
+ ${badge} + ${meta} +
+ ${action} +
+ ${sharing} +
`; + } + + body = ``; + } + } + return ` + `; + }; + + const loadConnections = async () => { + if (connectionsLoaded || connectionsLoading) return; + connectionsLoading = true; + render(); + const username = getUsername(); + if (!username) { connectionsLoading = false; return; } + try { + const token = getAccessToken(); + const headers: Record = token ? { Authorization: `Bearer ${token}` } : {}; + const [statusRes, sharingRes, spacesRes] = await Promise.all([ + fetch(`/api/oauth/status?space=${encodeURIComponent(username)}`, { headers }), + fetch(`/api/oauth/sharing?space=${encodeURIComponent(username)}`, { headers }), + fetch("/api/spaces", { headers }), + ]); + if (statusRes.ok) connectionsStatus = await statusRes.json(); + if (sharingRes.ok) sharingConfig = await sharingRes.json(); + if (spacesRes.ok) { + const data = await spacesRes.json(); + userSpaces = (data.spaces || []) + .filter((s: any) => s.role === "owner" || s.role === "admin" || s.role === "member") + .map((s: any) => ({ slug: s.slug, name: s.name })); + } + } catch { /* offline */ } + connectionsLoaded = true; + connectionsLoading = false; + render(); + }; + const loadGuardians = async () => { if (guardiansLoaded || guardiansLoading) return; guardiansLoading = true; @@ -1538,6 +1659,7 @@ export class RStackIdentity extends HTMLElement { if (openSection === "recovery") loadGuardians(); if (openSection === "address") loadAddresses(); if (openSection === "device") loadDevices(); + if (openSection === "connections") loadConnections(); render(); if (openSection === "email") setTimeout(() => (overlay.querySelector("#acct-email") as HTMLInputElement)?.focus(), 50); if (openSection === "recovery") setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50); @@ -1843,6 +1965,69 @@ export class RStackIdentity extends HTMLElement { }); }); + // Connections: connect buttons + overlay.querySelectorAll(".conn-btn--connect").forEach(btn => { + btn.addEventListener("click", () => { + const provider = (btn as HTMLElement).dataset.provider!; + const space = (btn as HTMLElement).dataset.space!; + const popup = window.open(`/api/oauth/${provider}/authorize?space=${encodeURIComponent(space)}`, "_blank", "width=600,height=700"); + const poll = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(poll); + connectionsLoaded = false; + loadConnections(); + } + }, 1500); + }); + }); + + // Connections: disconnect buttons + overlay.querySelectorAll(".conn-btn--disconnect").forEach(btn => { + btn.addEventListener("click", async () => { + const provider = (btn as HTMLElement).dataset.provider!; + const el = btn as HTMLButtonElement; + el.disabled = true; + el.textContent = "..."; + const username = getUsername() || ""; + try { + const token = getAccessToken(); + await fetch(`/api/oauth/${provider}/disconnect?space=${encodeURIComponent(username)}`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } catch { /* best-effort */ } + connectionsLoaded = false; + loadConnections(); + }); + }); + + // Connections: sharing checkboxes + overlay.querySelectorAll(".conn-share-cb").forEach(cb => { + cb.addEventListener("change", async () => { + const provider = cb.dataset.provider!; + const spaceSlug = cb.dataset.space!; + const current = sharingConfig[provider]?.spaces || []; + const updated = cb.checked + ? [...new Set([...current, spaceSlug])] + : current.filter(s => s !== spaceSlug); + + // Update local state immediately + if (!sharingConfig[provider]) sharingConfig[provider] = { spaces: [] }; + sharingConfig[provider].spaces = updated; + + // Persist to server + const username = getUsername() || ""; + const token = getAccessToken(); + try { + await fetch("/api/oauth/sharing", { + method: "POST", + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ space: username, provider, sharedSpaces: updated }), + }); + } catch { /* best-effort */ } + }); + }); + }; document.body.appendChild(overlay); @@ -2476,6 +2661,48 @@ const ACCOUNT_MODAL_STYLES = ` .shortcut-hint { margin: 8px 0 0; font-size: 0.7rem; color: var(--rs-text-muted); text-align: center; } +/* Connections section */ +.conn-list { display: flex; flex-direction: column; gap: 8px; } +.conn-card { + border: 1px solid var(--rs-border, #262626); border-radius: 10px; + padding: 10px 14px; transition: border-color 0.2s; +} +.conn-card:hover { border-color: var(--rs-text-muted, #525252); } +.conn-card--active { border-color: rgba(52,211,153,0.3); } +.conn-card--soon { opacity: 0.4; pointer-events: none; } +.conn-card-header { display: flex; align-items: center; gap: 10px; } +.conn-card-icon { + width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; + background: var(--rs-bg-surface, #0a0a0a); border: 1px solid var(--rs-border, #262626); + font-size: 0.85rem; font-weight: 700; color: var(--rs-text-primary); flex-shrink: 0; +} +.conn-card-info { flex: 1; min-width: 0; } +.conn-card-name { font-size: 0.82rem; font-weight: 600; } +.conn-badge { + display: inline-block; font-size: 0.62rem; font-weight: 600; padding: 2px 7px; border-radius: 6px; + text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; +} +.conn-badge--connected { background: rgba(52,211,153,0.15); color: #34d399; } +.conn-badge--disconnected { background: var(--rs-bg-surface, #0a0a0a); color: var(--rs-text-muted, #525252); } +.conn-badge--soon { background: transparent; color: var(--rs-text-muted, #525252); border: 1px solid var(--rs-border, #262626); } +.conn-meta { font-size: 0.72rem; color: var(--rs-text-muted, #525252); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.conn-btn { + padding: 5px 12px; border-radius: 6px; border: none; font-size: 0.72rem; font-weight: 600; + cursor: pointer; transition: opacity 0.15s; flex-shrink: 0; white-space: nowrap; +} +.conn-btn:hover { opacity: 0.85; } +.conn-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.conn-btn--connect { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } +.conn-btn--disconnect { background: rgba(239,68,68,0.12); color: #f87171; } +.conn-share-section { + margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border, #262626); +} +.conn-share-label { font-size: 0.68rem; font-weight: 600; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; } +.conn-share-opt { + display: inline-flex; align-items: center; gap: 4px; font-size: 0.78rem; color: var(--rs-text-primary); + margin-right: 12px; margin-bottom: 2px; cursor: pointer; +} +.conn-share-opt input[type="checkbox"] { margin: 0; accent-color: #06b6d4; } `; const ONBOARDING_STYLES = ` diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 78f93de..91850b9 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -518,11 +518,11 @@ export class RStackSpaceSwitcher extends HTMLElement { } /** Public entry point — both gear icons call this */ - openSettingsModal(slug: string, name: string, tab: "settings" | "modules" | "members" | "invitations" | "connections" = "settings") { + openSettingsModal(slug: string, name: string, tab: "settings" | "modules" | "members" | "invitations" = "settings") { this.#showEditSpaceModal(slug, name, tab); } - #showEditSpaceModal(slug: string, spaceName: string, initialTab: "settings" | "modules" | "members" | "invitations" | "connections" = "settings") { + #showEditSpaceModal(slug: string, spaceName: string, initialTab: "settings" | "modules" | "members" | "invitations" = "settings") { if (document.querySelector(".rstack-auth-overlay")) return; const overlay = document.createElement("div"); @@ -537,7 +537,6 @@ export class RStackSpaceSwitcher extends HTMLElement { -
@@ -573,10 +572,6 @@ export class RStackSpaceSwitcher extends HTMLElement {
Loading invitations...
- -
-
Loading connections...
-
`; @@ -587,24 +582,18 @@ export class RStackSpaceSwitcher extends HTMLElement { overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); // Tab switching - const editModal = overlay.querySelector(".edit-modal") as HTMLElement; 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 tabName = (tab as HTMLElement).dataset.tab; - const panel = overlay.querySelector(`#panel-${tabName}`); + const panel = overlay.querySelector(`#panel-${(tab as HTMLElement).dataset.tab}`); panel?.classList.remove("hidden"); - // Widen modal for connections tab - if (editModal) editModal.style.maxWidth = tabName === "connections" ? "680px" : ""; - // Lazy-load content - if (tabName === "modules") this.#loadModulesConfig(overlay, slug); - if (tabName === "members") this.#loadMembers(overlay, slug); - if (tabName === "invitations") this.#loadInvitations(overlay, slug); - if (tabName === "connections") this.#loadConnections(overlay, slug); + if ((tab as HTMLElement).dataset.tab === "modules") this.#loadModulesConfig(overlay, slug); + if ((tab as HTMLElement).dataset.tab === "members") this.#loadMembers(overlay, slug); + if ((tab as HTMLElement).dataset.tab === "invitations") this.#loadInvitations(overlay, slug); }); }); @@ -621,10 +610,6 @@ export class RStackSpaceSwitcher extends HTMLElement { if (initialTab === "modules") this.#loadModulesConfig(overlay, slug); if (initialTab === "members") this.#loadMembers(overlay, slug); if (initialTab === "invitations") this.#loadInvitations(overlay, slug); - if (initialTab === "connections") { - if (editModal) editModal.style.maxWidth = "680px"; - this.#loadConnections(overlay, slug); - } document.body.appendChild(overlay); } @@ -1091,163 +1076,6 @@ export class RStackSpaceSwitcher extends HTMLElement { } } - async #loadConnections(overlay: HTMLElement, slug: string) { - const container = overlay.querySelector("#es-connections-dashboard") as HTMLElement; - if (!container) return; - - interface PlatformDef { - id: string; - name: string; - icon: string; - oauthKey?: string; // matches key in status response - comingSoon?: boolean; - } - - const platforms: PlatformDef[] = [ - { id: "google", name: "Google", icon: "G", oauthKey: "google" }, - { id: "notion", name: "Notion", icon: "N", oauthKey: "notion" }, - { id: "clickup", name: "ClickUp", icon: "\u2713", oauthKey: "clickup" }, - { id: "telegram", name: "Telegram", icon: "\u2708", comingSoon: true }, - { id: "discord", name: "Discord", icon: "\uD83C\uDFAE", comingSoon: true }, - { id: "github", name: "GitHub", icon: "\uD83D\uDC19", comingSoon: true }, - { id: "slack", name: "Slack", icon: "#", comingSoon: true }, - { id: "twitter", name: "X / Twitter", icon: "X", comingSoon: true }, - { id: "bluesky", name: "Bluesky", icon: "\uD83E\uDD8B", comingSoon: true }, - { id: "linear", name: "Linear", icon: "Lin", comingSoon: true }, - ]; - - // Fetch connection status - let statusData: Record = {}; - try { - const token = getAccessToken(); - const res = await fetch(`/api/oauth/status?space=${encodeURIComponent(slug)}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (res.ok) statusData = await res.json(); - } catch { /* offline */ } - - const anyConnected = platforms.some(p => p.oauthKey && statusData[p.oauthKey]?.connected); - - // Build hub + grid - let html = ` -
-
-
r
-
rSpace
-
- -
`; - - for (const p of platforms) { - const info = p.oauthKey ? statusData[p.oauthKey] : undefined; - const connected = info?.connected ?? false; - const cardClass = p.comingSoon ? "conn-card conn-card--soon" : connected ? "conn-card conn-card--active" : "conn-card"; - - let badge = ""; - let meta = ""; - let action = ""; - - if (p.comingSoon) { - badge = `Coming Soon`; - } else if (connected) { - badge = `Connected`; - if (info?.email) meta = `
${info.email}
`; - else if (info?.workspaceName) meta = `
${info.workspaceName}
`; - else if (info?.teamName) meta = `
${info.teamName}
`; - action = ``; - } else { - badge = `Not connected`; - action = ``; - } - - html += ` -
-
${p.icon}
-
${p.name}
- ${badge} - ${meta} - ${action} -
`; - } - - html += `
`; - container.innerHTML = html; - - // Draw SVG connectors after layout settles - requestAnimationFrame(() => this.#drawConnectorLines(container, platforms, statusData)); - - // Connect handlers - container.querySelectorAll(".conn-btn--connect").forEach(btn => { - btn.addEventListener("click", () => { - const provider = (btn as HTMLElement).dataset.provider!; - const popup = window.open(`/api/oauth/${provider}/authorize?space=${encodeURIComponent(slug)}`, "_blank", "width=600,height=700"); - // Poll for popup close to refresh status - const poll = setInterval(() => { - if (!popup || popup.closed) { - clearInterval(poll); - this.#loadConnections(overlay, slug); - } - }, 1500); - }); - }); - - // Disconnect handlers - container.querySelectorAll(".conn-btn--disconnect").forEach(btn => { - btn.addEventListener("click", async () => { - const provider = (btn as HTMLElement).dataset.provider!; - const el = btn as HTMLButtonElement; - el.disabled = true; - el.textContent = "..."; - try { - const token = getAccessToken(); - await fetch(`/api/oauth/${provider}/disconnect?space=${encodeURIComponent(slug)}`, { - method: "POST", - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - } catch { /* best-effort */ } - this.#loadConnections(overlay, slug); - }); - }); - } - - #drawConnectorLines( - container: HTMLElement, - platforms: Array<{ id: string; oauthKey?: string; comingSoon?: boolean }>, - statusData: Record, - ) { - const svg = container.querySelector("#conn-svg") as SVGElement | null; - const hub = container.querySelector(".conn-hub") as HTMLElement | null; - if (!svg || !hub) return; - - const containerRect = container.getBoundingClientRect(); - const hubRect = hub.getBoundingClientRect(); - const hubCx = hubRect.left + hubRect.width / 2 - containerRect.left; - const hubCy = hubRect.top + hubRect.height / 2 - containerRect.top; - - // Size SVG to container - svg.setAttribute("width", String(containerRect.width)); - svg.setAttribute("height", String(containerRect.height)); - - let paths = ""; - for (const p of platforms) { - const card = container.querySelector(`[data-platform="${p.id}"]`) as HTMLElement | null; - if (!card) continue; - - const cardRect = card.getBoundingClientRect(); - const cardCx = cardRect.left + cardRect.width / 2 - containerRect.left; - const cardCy = cardRect.top - containerRect.top; // top edge of card - - const connected = p.oauthKey ? statusData[p.oauthKey]?.connected : false; - const lineClass = p.comingSoon ? "conn-line conn-line--soon" : connected ? "conn-line conn-line--active" : "conn-line conn-line--idle"; - - // Bezier from hub bottom to card top - const midY = hubCy + (cardCy - hubCy) * 0.5; - paths += ``; - } - - svg.innerHTML = paths; - } - async #loadModulesConfig(overlay: HTMLElement, slug: string) { const container = overlay.querySelector("#es-modules-list") as HTMLElement; const statusEl = overlay.querySelector("#es-modules-status") as HTMLElement; @@ -2375,81 +2203,6 @@ input:checked + .toggle-slider:before { transform: translateX(16px); } .add-selected-user { min-height: 0; } .add-feedback { font-size: 0.78rem; min-height: 16px; } -/* Connections dashboard */ -.conn-dashboard { min-height: 200px; } -.conn-container { position: relative; display: flex; flex-direction: column; align-items: center; gap: 1.25rem; padding: 0.5rem 0; } -.conn-hub { - display: flex; flex-direction: column; align-items: center; gap: 4px; - padding: 12px 24px; border-radius: 12px; - background: linear-gradient(135deg, rgba(6,182,212,0.1), rgba(124,58,237,0.1)); - border: 1px solid rgba(6,182,212,0.25); -} -.conn-hub--active { animation: connPulse 2s ease-in-out infinite; } -.conn-hub-icon { - width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; font-weight: 700; font-size: 1.1rem; -} -.conn-hub-label { font-size: 0.72rem; font-weight: 600; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; } - -.conn-svg { - position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; -} -.conn-line { fill: none; stroke-width: 1.5; } -.conn-line--active { stroke: #34d399; stroke-dasharray: 6 3; animation: connDash 1.5s linear infinite; } -.conn-line--idle { stroke: var(--rs-border, #404040); stroke-dasharray: 4 4; opacity: 0.6; } -.conn-line--soon { stroke: var(--rs-border, #404040); stroke-dasharray: 2 4; opacity: 0.25; } - -.conn-grid { - display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; width: 100%; -} -.conn-card { - display: flex; flex-direction: column; align-items: center; gap: 6px; - padding: 14px 8px 10px; border-radius: 10px; - border: 1px solid var(--rs-border, #262626); background: var(--rs-bg-hover, #171717); - text-align: center; transition: border-color 0.2s, box-shadow 0.2s; -} -.conn-card:hover { border-color: var(--rs-text-muted, #525252); } -.conn-card--active { border-color: rgba(52,211,153,0.4); box-shadow: 0 0 8px rgba(52,211,153,0.08); } -.conn-card--soon { opacity: 0.45; pointer-events: none; } - -.conn-card-icon { - width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; - background: var(--rs-bg-surface, #0a0a0a); border: 1px solid var(--rs-border, #262626); - font-size: 0.95rem; font-weight: 700; color: var(--rs-text-primary); -} -.conn-card-name { font-size: 0.78rem; font-weight: 600; color: var(--rs-text-primary); } - -.conn-badge { - font-size: 0.62rem; font-weight: 600; padding: 2px 7px; border-radius: 6px; - text-transform: uppercase; letter-spacing: 0.04em; -} -.conn-badge--connected { background: rgba(52,211,153,0.15); color: #34d399; } -.conn-badge--disconnected { background: var(--rs-bg-surface, #0a0a0a); color: var(--rs-text-muted, #525252); } -.conn-badge--soon { background: transparent; color: var(--rs-text-muted, #525252); border: 1px solid var(--rs-border, #262626); } - -.conn-meta { font-size: 0.68rem; color: var(--rs-text-muted, #525252); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; } - -.conn-btn { - padding: 4px 12px; border-radius: 6px; border: none; - font-size: 0.7rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; - margin-top: 2px; -} -.conn-btn:hover { opacity: 0.85; } -.conn-btn:disabled { opacity: 0.4; cursor: not-allowed; } -.conn-btn--connect { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } -.conn-btn--disconnect { background: rgba(239,68,68,0.12); color: #f87171; } - -@keyframes connPulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(6,182,212,0.15); } - 50% { box-shadow: 0 0 12px 4px rgba(6,182,212,0.15); } -} -@keyframes connDash { to { stroke-dashoffset: -18; } } - -@media (max-width: 480px) { - .conn-svg { display: none; } - .conn-grid { grid-template-columns: repeat(2, 1fr); } -} - @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } `;