refactor(connections): move from space settings to user account

Connections are per-user, not per-space. Move the platform connections
dashboard from the space settings modal (5th tab) to the My Account
modal as a collapsible section. Add selective sharing: users connect
platforms to their personal data store, then choose which community
spaces to share data into via per-provider space checkboxes.

New endpoints: GET/POST /api/oauth/sharing for per-user sharing config.
Sharing config stored as Automerge doc {userSpace}:oauth:sharing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 15:52:15 -07:00
parent bed124f869
commit 32093a0fc3
3 changed files with 294 additions and 254 deletions

View File

@ -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<string, { spaces: string[] }>; // 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<SharingDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SharingDoc>(), '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<SharingDoc>(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<SharingDoc>(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 };

View File

@ -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<string, { connected: boolean; email?: string; workspaceName?: string; teamName?: string; connectedAt?: number }> = {};
let sharingConfig: Record<string, { spaces: string[] }> = {};
let userSpaces: Array<{ slug: string; name: string }> = [];
let emailStep: "input" | "verify" = "input";
let emailAddr = "";
@ -1224,6 +1231,7 @@ export class RStackIdentity extends HTMLElement {
</div>
${renderShortcutsSection()}
${renderConnectionsSection()}
<div class="error" id="acct-error"></div>
</div>
@ -1458,6 +1466,119 @@ export class RStackIdentity extends HTMLElement {
</div>`;
};
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 = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading connections...</div></div>`;
} 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 = `<span class="conn-badge conn-badge--soon">Coming Soon</span>`;
} else if (connected) {
badge = `<span class="conn-badge conn-badge--connected">Connected</span>`;
if (info?.email) meta = `<div class="conn-meta">${info.email}</div>`;
else if (info?.workspaceName) meta = `<div class="conn-meta">${info.workspaceName}</div>`;
else if (info?.teamName) meta = `<div class="conn-meta">${info.teamName}</div>`;
action = `<button class="conn-btn conn-btn--disconnect" data-provider="${p.oauthKey}">Disconnect</button>`;
// 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 `<label class="conn-share-opt"><input type="checkbox" class="conn-share-cb" data-provider="${p.oauthKey}" data-space="${s.slug}" ${checked} />${s.name}</label>`;
}).join("");
sharing = `<div class="conn-share-section"><div class="conn-share-label">Share to:</div>${spaceChecks}</div>`;
}
} else {
badge = `<span class="conn-badge conn-badge--disconnected">Not connected</span>`;
action = `<button class="conn-btn conn-btn--connect" data-provider="${p.oauthKey}" data-space="${username}">Connect</button>`;
}
cards += `
<div class="${cardClass}" data-platform="${p.id}">
<div class="conn-card-header">
<div class="conn-card-icon">${p.icon}</div>
<div class="conn-card-info">
<div class="conn-card-name">${p.name}</div>
${badge}
${meta}
</div>
${action}
</div>
${sharing}
</div>`;
}
body = `<div class="account-section-body"><div class="conn-list">${cards}</div></div>`;
}
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="connections">
<span>${anyConnected ? '<span class="status-dot done"></span>' : '<span class="status-dot pending"></span>'} 🔗 Connections${anyConnected ? ` <span style="font-size:0.75rem;color:var(--rs-text-muted)">(${connectedCount})</span>` : ""}</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</div>`;
};
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<string, string> = 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<HTMLInputElement>(".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 = `

View File

@ -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 {
<button class="tab ${initialTab === "modules" ? "active" : ""}" data-tab="modules">Modules</button>
<button class="tab ${initialTab === "members" ? "active" : ""}" data-tab="members">Members</button>
<button class="tab ${initialTab === "invitations" ? "active" : ""}" data-tab="invitations">Invitations</button>
<button class="tab ${initialTab === "connections" ? "active" : ""}" data-tab="connections">Connections</button>
</div>
<div class="tab-panel ${initialTab !== "settings" ? "hidden" : ""}" id="panel-settings">
@ -573,10 +572,6 @@ export class RStackSpaceSwitcher extends HTMLElement {
<div class="tab-panel ${initialTab !== "invitations" ? "hidden" : ""}" id="panel-invitations">
<div id="es-invitations-list" class="invitation-list"><div class="loading">Loading invitations...</div></div>
</div>
<div class="tab-panel ${initialTab !== "connections" ? "hidden" : ""}" id="panel-connections">
<div id="es-connections-dashboard" class="conn-dashboard"><div class="loading">Loading connections...</div></div>
</div>
</div>
`;
@ -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<string, { connected: boolean; email?: string; workspaceName?: string; teamName?: string; connectedAt?: number }> = {};
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 = `
<div class="conn-container">
<div class="conn-hub${anyConnected ? " conn-hub--active" : ""}">
<div class="conn-hub-icon">r</div>
<div class="conn-hub-label">rSpace</div>
</div>
<svg class="conn-svg" id="conn-svg"></svg>
<div class="conn-grid">`;
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 = `<span class="conn-badge conn-badge--soon">Coming Soon</span>`;
} else if (connected) {
badge = `<span class="conn-badge conn-badge--connected">Connected</span>`;
if (info?.email) meta = `<div class="conn-meta">${info.email}</div>`;
else if (info?.workspaceName) meta = `<div class="conn-meta">${info.workspaceName}</div>`;
else if (info?.teamName) meta = `<div class="conn-meta">${info.teamName}</div>`;
action = `<button class="conn-btn conn-btn--disconnect" data-provider="${p.oauthKey}">Disconnect</button>`;
} else {
badge = `<span class="conn-badge conn-badge--disconnected">Not connected</span>`;
action = `<button class="conn-btn conn-btn--connect" data-provider="${p.oauthKey}">Connect</button>`;
}
html += `
<div class="${cardClass}" data-platform="${p.id}">
<div class="conn-card-icon">${p.icon}</div>
<div class="conn-card-name">${p.name}</div>
${badge}
${meta}
${action}
</div>`;
}
html += `</div></div>`;
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<string, { connected: boolean }>,
) {
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 += `<path class="${lineClass}" d="M${hubCx},${hubCy + hubRect.height / 2} C${hubCx},${midY} ${cardCx},${midY} ${cardCx},${cardCy}" />`;
}
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); } }
`;