/** * — Space-centric dashboard shown when all tabs are closed. * * Sections: space header + stats, members, tools open, recent activity, * active votes, and quick actions. * * Attributes: * space — current space slug */ import { getSession, getAccessToken } from "./rstack-identity"; interface MemberInfo { did: string; username: string; displayName: string; } interface NotificationItem { id: string; category: string; title: string; body: string | null; actionUrl: string | null; createdAt: string; read: boolean; } interface ProposalInfo { id: string; title: string; status: string; score: number; vote_count: string; created_at: string; } interface TabLayer { moduleId: string; label?: string; icon?: string; } const CACHE_TTL = 30_000; // 30s export class RStackUserDashboard extends HTMLElement { #shadow: ShadowRoot; #members: MemberInfo[] = []; #notifications: NotificationItem[] = []; #proposals: ProposalInfo[] = []; #openTabs: TabLayer[] = []; #membersLoading = true; #notifLoading = true; #proposalsLoading = true; #lastFetch = 0; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } static get observedAttributes() { return ["space"]; } get space(): string { return this.getAttribute("space") || ""; } connectedCallback() { this.#render(); this.#loadData(); } attributeChangedCallback() { // Space changed — reset cache this.#lastFetch = 0; this.#render(); } /** Called from shell.ts before tabs are cleared */ setOpenTabs(tabs: TabLayer[]) { this.#openTabs = tabs; } /** Reload data when dashboard becomes visible again */ refresh() { if (Date.now() - this.#lastFetch < CACHE_TTL) { this.#render(); return; } this.#membersLoading = true; this.#notifLoading = true; this.#proposalsLoading = true; this.#render(); this.#loadData(); } async #loadData() { this.#lastFetch = Date.now(); await Promise.all([ this.#fetchMembers(), this.#fetchNotifications(), this.#fetchProposals(), ]); } async #fetchMembers() { this.#membersLoading = true; try { const res = await fetch(`/${encodeURIComponent(this.space)}/api/space-members`); if (res.ok) { const data = await res.json(); this.#members = data.members || []; } } catch { /* offline */ } this.#membersLoading = false; this.#render(); } async #fetchNotifications() { this.#notifLoading = true; const token = getAccessToken(); if (!token) { this.#notifLoading = false; this.#render(); return; } try { const res = await fetch("/api/notifications?limit=10", { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { const data = await res.json(); this.#notifications = data.notifications || []; } } catch { /* offline */ } this.#notifLoading = false; this.#render(); } async #fetchProposals() { this.#proposalsLoading = true; try { const slug = encodeURIComponent(this.space); const res = await fetch(`/${slug}/rvote/api/proposals?space_slug=${slug}&limit=5`); if (res.ok) { const data = await res.json(); this.#proposals = (data.proposals || []).filter( (p: ProposalInfo) => p.status !== "completed" && p.status !== "rejected", ); } } catch { // rvote not installed or offline — hide section this.#proposals = []; } this.#proposalsLoading = false; this.#render(); } #timeAgo(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diff / 60_000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 30) return `${days}d ago`; return `${Math.floor(days / 30)}mo ago`; } #dispatch(moduleId: string) { this.dispatchEvent(new CustomEvent("dashboard-navigate", { bubbles: true, detail: { moduleId, spaceSlug: this.space }, })); } #parseActionUrl(url: string | null): { moduleId: string; spaceSlug?: string } | null { if (!url) return null; try { const parts = url.replace(/^\//, "").split("/"); if (parts.length >= 2) return { spaceSlug: parts[0], moduleId: parts[1] }; if (parts.length === 1 && parts[0]) return { moduleId: parts[0] }; } catch { /* fall through */ } return null; } #memberInitial(m: MemberInfo): string { return (m.displayName || m.username || "?").charAt(0).toUpperCase(); } // Module icons for "tools open" chips #moduleIcon(moduleId: string): string { const icons: Record = { rspace: "\u{1F30C}", rnotes: "\u{1F4DD}", rmeets: "\u{1F4F9}", rvote: "\u{1F5F3}", rnetwork: "\u{1F465}", rcalendar: "\u{1F4C5}", rtasks: "\u{2705}", rwallet: "\u{1F4B0}", rmail: "\u{1F4E7}", rmaps: "\u{1F5FA}", rprompt: "\u{1F916}", rzine: "\u{1F4D6}", rfiles: "\u{1F4C1}", }; return icons[moduleId] || "\u{1F4E6}"; } #moduleLabel(tab: TabLayer): string { if (tab.label) return tab.label; // Capitalize moduleId const id = tab.moduleId; return id.charAt(0).toUpperCase() + id.slice(1); } #render() { const session = getSession(); const space = this.space; const spaceName = space.charAt(0).toUpperCase() + space.slice(1); // ── Stats pills ── const statPills: string[] = []; if (!this.#membersLoading) { statPills.push(`${this.#members.length} member${this.#members.length !== 1 ? "s" : ""}`); } if (!this.#proposalsLoading && this.#proposals.length > 0) { statPills.push(`${this.#proposals.length} active proposal${this.#proposals.length !== 1 ? "s" : ""}`); } if (this.#openTabs.length > 0) { statPills.push(`${this.#openTabs.length} tab${this.#openTabs.length !== 1 ? "s" : ""} open`); } // ── Members ── let membersHTML: string; if (this.#membersLoading) { membersHTML = `
Loading members...
`; } else if (this.#members.length === 0) { membersHTML = `
No members found
`; } else { membersHTML = this.#members.map(m => ` `).join(""); } // ── Tools open ── let toolsHTML: string; if (this.#openTabs.length === 0) { toolsHTML = `
No tools were open
`; } else { toolsHTML = this.#openTabs.map(t => ` `).join(""); } // ── Notifications / Recent Activity ── let activityHTML: string; if (!session) { activityHTML = `
Sign in to see recent activity
`; } else if (this.#notifLoading) { activityHTML = `
Loading...
`; } else if (this.#notifications.length === 0) { activityHTML = `
No recent activity
`; } else { activityHTML = this.#notifications.map(n => ` `).join(""); } // ── Active Votes ── let votesHTML = ""; if (!this.#proposalsLoading && this.#proposals.length > 0) { votesHTML = `

Active Votes

${this.#proposals.map(p => ` `).join("")}
`; } // ── Quick Actions ── const hasProposals = this.#proposals.length > 0; this.#shadow.innerHTML = `
${spaceName.charAt(0)}

${spaceName}

${statPills.length > 0 ? `
${statPills.join("")}
` : ""}

Members

${membersHTML}

Tools Open

${toolsHTML}

Recent Activity

${activityHTML}
${votesHTML}

Quick Actions

${hasProposals ? ` ` : ""}
`; this.#attachEvents(); } #attachEvents() { // Member cards → open rNetwork this.#shadow.querySelectorAll(".member-card").forEach(el => { el.addEventListener("click", () => { this.#dispatch((el as HTMLElement).dataset.navigate || "rnetwork"); }); }); // Tool chips → re-open tab this.#shadow.querySelectorAll(".tool-chip").forEach(el => { el.addEventListener("click", () => { this.#dispatch((el as HTMLElement).dataset.module!); }); }); // Activity items this.#shadow.querySelectorAll(".activity-item").forEach(el => { el.addEventListener("click", () => { const url = (el as HTMLElement).dataset.actionUrl; const parsed = this.#parseActionUrl(url || null); if (parsed) { this.#dispatch(parsed.moduleId); } else { this.#dispatch("rspace"); } }); }); // Vote items this.#shadow.querySelectorAll(".vote-item").forEach(el => { el.addEventListener("click", () => { this.#dispatch((el as HTMLElement).dataset.navigate || "rvote"); }); }); // Quick action buttons this.#shadow.querySelectorAll(".action-btn").forEach(el => { el.addEventListener("click", () => { this.#dispatch((el as HTMLElement).dataset.actionModule!); }); }); } static define(tag = "rstack-user-dashboard") { if (!customElements.get(tag)) customElements.define(tag, RStackUserDashboard); } } // ============================================================================ // STYLES // ============================================================================ const STYLES = ` :host { display: block; position: fixed; top: 92px; left: 0; right: 0; bottom: 0; overflow-y: auto; background: var(--rs-bg, var(--rs-bg-page, #0f172a)); z-index: 1; } .dashboard { padding: 32px 24px; } .dashboard-inner { max-width: 800px; margin: 0 auto; display: flex; flex-direction: column; gap: 28px; } /* ── Space header ── */ .space-header { display: flex; align-items: center; gap: 16px; } .space-icon { width: 48px; height: 48px; border-radius: 12px; background: linear-gradient(135deg, rgba(6,182,212,0.2), rgba(94,234,212,0.12)); color: var(--rs-accent, #06b6d4); display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 700; flex-shrink: 0; } .space-title { margin: 0; font-size: 1.25rem; font-weight: 700; color: var(--rs-text-primary, #e2e8f0); } .stat-row { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; } .stat-pill { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: var(--rs-bg-surface, rgba(255,255,255,0.06)); color: var(--rs-text-muted, #94a3b8); white-space: nowrap; } /* ── Two-column grid ── */ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } /* ── Sections ── */ .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .section-header h2 { margin: 0; font-size: 0.85rem; font-weight: 600; color: var(--rs-text-secondary, #cbd5e1); text-transform: uppercase; letter-spacing: 0.04em; } .section-empty { padding: 20px 16px; text-align: center; color: var(--rs-text-muted, #94a3b8); font-size: 0.78rem; } /* ── Members ── */ .members-list { display: flex; flex-direction: column; border-radius: 10px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); overflow: hidden; } .member-card { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; transition: background 0.15s; border: none; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06)); background: none; text-align: left; color: inherit; font: inherit; width: 100%; } .member-card:last-child { border-bottom: none; } .member-card:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } .member-avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(135deg, rgba(6,182,212,0.2), rgba(94,234,212,0.12)); color: var(--rs-accent, #06b6d4); display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: 700; flex-shrink: 0; } .member-info { min-width: 0; } .member-name { font-size: 0.8rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .member-handle { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); } /* ── Tools open ── */ .tools-grid { display: flex; flex-wrap: wrap; gap: 8px; } .tool-chip { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 8px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); color: var(--rs-text-primary, #e2e8f0); font-size: 0.78rem; font-weight: 500; cursor: pointer; transition: background 0.15s, border-color 0.15s; font: inherit; } .tool-chip:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.06)); border-color: var(--rs-accent, #06b6d4); } .tool-icon { font-size: 0.95rem; } /* ── Recent Activity ── */ .activity-list { display: flex; flex-direction: column; border-radius: 10px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); overflow: hidden; } .activity-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 16px; cursor: pointer; transition: background 0.15s; border: none; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06)); background: none; text-align: left; color: inherit; font: inherit; width: 100%; } .activity-item:last-child { border-bottom: none; } .activity-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } .activity-item.unread { background: rgba(6,182,212,0.04); } .activity-item.unread .activity-title { font-weight: 600; } .activity-content { flex: 1; min-width: 0; } .activity-title { font-size: 0.8rem; color: var(--rs-text-primary, #e2e8f0); line-height: 1.3; } .activity-body { font-size: 0.73rem; color: var(--rs-text-muted, #94a3b8); margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .activity-time { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); flex-shrink: 0; white-space: nowrap; margin-top: 2px; } /* ── Active Votes ── */ .votes-list { display: flex; flex-direction: column; border-radius: 10px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); overflow: hidden; } .vote-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; cursor: pointer; transition: background 0.15s; border: none; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06)); background: none; text-align: left; color: inherit; font: inherit; width: 100%; } .vote-item:last-child { border-bottom: none; } .vote-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } .vote-info { flex: 1; min-width: 0; } .vote-title { font-size: 0.8rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vote-meta { display: flex; gap: 8px; margin-top: 3px; } .vote-status { font-size: 0.65rem; padding: 1px 6px; border-radius: 4px; background: rgba(251,191,36,0.15); color: #fbbf24; font-weight: 600; text-transform: uppercase; } .vote-count { font-size: 0.7rem; color: var(--rs-text-muted, #94a3b8); } .vote-score { font-size: 0.85rem; font-weight: 700; color: var(--rs-accent, #06b6d4); flex-shrink: 0; } /* ── Quick Actions ── */ .actions-row { display: flex; gap: 10px; flex-wrap: wrap; } .action-btn { display: flex; align-items: center; gap: 8px; padding: 10px 18px; border-radius: 8px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); color: var(--rs-text-primary, #e2e8f0); font-size: 0.8rem; font-weight: 500; cursor: pointer; transition: background 0.15s, border-color 0.15s; font: inherit; } .action-btn:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.06)); border-color: var(--rs-accent, #06b6d4); } .action-icon { font-size: 1rem; } /* ── Responsive ── */ @media (max-width: 640px) { :host { position: static; overflow-y: visible; } .dashboard { padding: 20px 12px; } .two-col { grid-template-columns: 1fr; } } @media (max-width: 480px) { .dashboard { padding: 16px 8px; } .actions-row { flex-direction: column; } .action-btn { justify-content: center; } } `;