/** * — Customizable space dashboard with widget cards. * * Always accessible via the persistent home icon in the tab bar. * Users can toggle and reorder summary widget cards from any rApp. * * Attributes: * space — current space slug */ import { getSession, getAccessToken } from "./rstack-identity"; import { getModuleApiBase } from "../url-helpers"; 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 TabLayer { moduleId: string; label?: string; icon?: string; } interface WidgetConfig { id: string; enabled: boolean; order: number; } interface SummaryTask { id: string; title: string; status: string; priority: string; createdAt: number; } interface SummaryEvent { title: string; start: string; end: string; allDay: boolean; location: string | null; } interface SummaryFlow { id: string; name: string; nodeCount: number; createdAt: number; } interface WalletBalance { tokenId: string; symbol: string; balance: number; } // ── Widget registry ── const WIDGET_REGISTRY: Record = { tasks: { label: "Open Tasks", icon: "\u{1F4CB}", requiresAuth: false, defaultEnabled: true }, calendar: { label: "Upcoming Events", icon: "\u{1F4C5}", requiresAuth: false, defaultEnabled: true }, activity: { label: "Recent Activity", icon: "\u{1F514}", requiresAuth: true, defaultEnabled: true }, members: { label: "Members", icon: "\u{1F465}", requiresAuth: false, defaultEnabled: true }, tools: { label: "Recently Open", icon: "\u{1F527}", requiresAuth: false, defaultEnabled: true }, quickactions: { label: "Quick Actions", icon: "\u26A1", requiresAuth: false, defaultEnabled: true }, wallet: { label: "Wallet Balances", icon: "\u{1F4B0}", requiresAuth: true, defaultEnabled: false }, flows: { label: "Active Flows", icon: "\u{1F30A}", requiresAuth: false, defaultEnabled: false }, }; const WIDGET_ORDER = ["tasks", "calendar", "activity", "members", "tools", "quickactions", "wallet", "flows"]; const CACHE_TTL = 30_000; // 30s const PRIORITY_COLORS: Record = { HIGH: "#ef4444", MEDIUM: "#f59e0b", LOW: "#22c55e", }; export class RStackUserDashboard extends HTMLElement { #shadow: ShadowRoot; #members: MemberInfo[] = []; #notifications: NotificationItem[] = []; #openTabs: TabLayer[] = []; #tasks: SummaryTask[] = []; #events: SummaryEvent[] = []; #flows: SummaryFlow[] = []; #walletBalances: WalletBalance[] = []; #membersLoading = true; #notifLoading = true; #summaryLoading = true; #walletLoading = true; #lastFetch = 0; #widgetConfigs: WidgetConfig[] = []; #isCustomizing = false; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } static get observedAttributes() { return ["space"]; } get space(): string { return this.getAttribute("space") || ""; } connectedCallback() { this.#loadWidgetConfigs(); this.#render(); if (this.offsetParent !== null) { this.#loadData(); } } attributeChangedCallback() { this.#lastFetch = 0; this.#loadWidgetConfigs(); this.#render(); } setOpenTabs(tabs: TabLayer[]) { this.#openTabs = tabs; } refresh() { if (Date.now() - this.#lastFetch < CACHE_TTL) { this.#render(); return; } this.#membersLoading = true; this.#notifLoading = true; this.#summaryLoading = true; this.#walletLoading = true; this.#render(); this.#loadData(); } // ── Widget config persistence ── #widgetConfigKey(): string { return `rspace_dashboard_widgets_${this.space || "default"}`; } #loadWidgetConfigs() { try { const raw = localStorage.getItem(this.#widgetConfigKey()); if (raw) { const parsed = JSON.parse(raw) as WidgetConfig[]; if (Array.isArray(parsed) && parsed.length > 0) { // Merge with registry in case new widgets were added const known = new Set(parsed.map(c => c.id)); let maxOrder = Math.max(...parsed.map(c => c.order)); for (const id of WIDGET_ORDER) { if (!known.has(id)) { const w = WIDGET_REGISTRY[id]; parsed.push({ id, enabled: w.defaultEnabled, order: ++maxOrder }); } } this.#widgetConfigs = parsed; return; } } } catch { /* ignore */ } // Default config this.#widgetConfigs = WIDGET_ORDER.map((id, i) => ({ id, enabled: WIDGET_REGISTRY[id].defaultEnabled, order: i, })); } #saveWidgetConfigs() { try { localStorage.setItem(this.#widgetConfigKey(), JSON.stringify(this.#widgetConfigs)); } catch { /* ignore */ } } // ── Data loading ── async #loadData() { this.#lastFetch = Date.now(); const enabled = new Set(this.#widgetConfigs.filter(c => c.enabled).map(c => c.id)); const fetches: Promise[] = []; if (enabled.has("members")) fetches.push(this.#fetchMembers()); else { this.#membersLoading = false; } if (enabled.has("activity")) fetches.push(this.#fetchNotifications()); else { this.#notifLoading = false; } if (enabled.has("tasks") || enabled.has("calendar") || enabled.has("flows")) { fetches.push(this.#fetchSummary()); } else { this.#summaryLoading = false; } if (enabled.has("wallet")) fetches.push(this.#fetchWallet()); else { this.#walletLoading = false; } await Promise.all(fetches); } async #fetchMembers() { this.#membersLoading = true; try { const res = await fetch(`${getModuleApiBase("rspace")}/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=5", { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { const data = await res.json(); this.#notifications = data.notifications || []; } } catch { /* offline */ } this.#notifLoading = false; this.#render(); } async #fetchSummary() { this.#summaryLoading = true; try { const res = await fetch(`/api/dashboard-summary/${encodeURIComponent(this.space)}`); if (res.ok) { const data = await res.json(); this.#tasks = data.tasks || []; this.#events = data.calendar || []; this.#flows = data.flows || []; } } catch { /* offline */ } this.#summaryLoading = false; this.#render(); } async #fetchWallet() { this.#walletLoading = true; const token = getAccessToken(); if (!token) { this.#walletLoading = false; this.#render(); return; } try { const res = await fetch("/api/crdt-tokens/my-balances", { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { const data = await res.json(); this.#walletBalances = data.balances || []; } } catch { /* offline */ } this.#walletLoading = false; this.#render(); } // ── Helpers ── #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`; } #formatEventTime(iso: string, allDay: boolean): string { if (allDay) return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" }); return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } #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(); } #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: "\u2705", 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; const id = tab.moduleId; return id.charAt(0).toUpperCase() + id.slice(1); } // ── Per-widget renderers ── #renderWidgetTasks(): string { if (this.#summaryLoading) return `
Loading...
`; if (this.#tasks.length === 0) return `
No open tasks
`; return `
${this.#tasks.map(t => ` `).join("")}
`; } #renderWidgetCalendar(): string { if (this.#summaryLoading) return `
Loading...
`; if (this.#events.length === 0) return `
No upcoming events
`; return `
${this.#events.map(e => ` `).join("")}
`; } #renderWidgetActivity(): string { const session = getSession(); if (!session) return `
Sign in to see recent activity
`; if (this.#notifLoading) return `
Loading...
`; if (this.#notifications.length === 0) return `
No recent activity
`; return `
${this.#notifications.map(n => ` `).join("")}
`; } #renderWidgetMembers(): string { if (this.#membersLoading) return `
Loading members...
`; if (this.#members.length === 0) return `
No members found
`; return `
${this.#members.map(m => ` `).join("")} ${this.#members.length} member${this.#members.length !== 1 ? "s" : ""}
`; } #renderWidgetTools(): string { if (this.#openTabs.length === 0) return `
No recent tabs
`; return `
${this.#openTabs.map(t => ` `).join("")}
`; } #renderWidgetQuickActions(): string { return `
`; } #renderWidgetWallet(): string { const session = getSession(); if (!session) return `
Sign in to see wallet
`; if (this.#walletLoading) return `
Loading...
`; if (this.#walletBalances.length === 0) return `
No token balances
`; return `
${this.#walletBalances.map(b => ` `).join("")}
`; } #renderWidgetFlows(): string { if (this.#summaryLoading) return `
Loading...
`; if (this.#flows.length === 0) return `
No active flows
`; return `
${this.#flows.map(f => ` `).join("")}
`; } #escHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // ── Widget card wrapper ── #renderWidget(config: WidgetConfig): string { const w = WIDGET_REGISTRY[config.id]; if (!w) return ""; const renderers: Record string> = { tasks: () => this.#renderWidgetTasks(), calendar: () => this.#renderWidgetCalendar(), activity: () => this.#renderWidgetActivity(), members: () => this.#renderWidgetMembers(), tools: () => this.#renderWidgetTools(), quickactions: () => this.#renderWidgetQuickActions(), wallet: () => this.#renderWidgetWallet(), flows: () => this.#renderWidgetFlows(), }; const count = this.#getWidgetCount(config.id); const countBadge = count !== null ? `${count}` : ""; const content = renderers[config.id]?.() || ""; return `
${w.icon} ${w.label} ${countBadge}
${content}
`; } #getWidgetCount(id: string): number | null { switch (id) { case "tasks": return this.#summaryLoading ? null : this.#tasks.length || null; case "calendar": return this.#summaryLoading ? null : this.#events.length || null; case "activity": return this.#notifLoading ? null : this.#notifications.filter(n => !n.read).length || null; case "members": return this.#membersLoading ? null : this.#members.length || null; case "flows": return this.#summaryLoading ? null : this.#flows.length || null; default: return null; } } // ── Customize panel ── #renderCustomizePanel(): string { const sorted = [...this.#widgetConfigs].sort((a, b) => a.order - b.order); return `
Customize Dashboard
${sorted.map((c, i) => { const w = WIDGET_REGISTRY[c.id]; if (!w) return ""; return `
${i > 0 ? `` : ``} ${i < sorted.length - 1 ? `` : ``}
`; }).join("")}
`; } // ── Main render ── #render() { 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.#openTabs.length > 0) { statPills.push(`${this.#openTabs.length} tab${this.#openTabs.length !== 1 ? "s" : ""} open`); } // Enabled widgets sorted by order const enabled = this.#widgetConfigs .filter(c => c.enabled) .sort((a, b) => a.order - b.order); this.#shadow.innerHTML = `
${spaceName.charAt(0)}

${spaceName}

${statPills.length > 0 ? `
${statPills.join("")}
` : ""}
${this.#isCustomizing ? this.#renderCustomizePanel() : ""} ${enabled.map(c => this.#renderWidget(c)).join("")}
`; this.#attachEvents(); } #attachEvents() { // Customize button this.#shadow.getElementById("customize-btn")?.addEventListener("click", () => { this.#isCustomizing = !this.#isCustomizing; this.#render(); }); // Customize done button this.#shadow.getElementById("customize-done")?.addEventListener("click", () => { this.#isCustomizing = false; this.#render(); // Reload data for newly enabled widgets this.#lastFetch = 0; this.#loadData(); }); // Widget toggles this.#shadow.querySelectorAll("[data-widget-toggle]").forEach(input => { input.addEventListener("change", () => { const id = input.dataset.widgetToggle!; const config = this.#widgetConfigs.find(c => c.id === id); if (config) { config.enabled = input.checked; this.#saveWidgetConfigs(); this.#render(); } }); }); // Move up/down arrows this.#shadow.querySelectorAll("[data-move-up]").forEach(btn => { btn.addEventListener("click", () => this.#moveWidget(btn.dataset.moveUp!, -1)); }); this.#shadow.querySelectorAll("[data-move-down]").forEach(btn => { btn.addEventListener("click", () => this.#moveWidget(btn.dataset.moveDown!, 1)); }); // Navigation from widget items this.#shadow.querySelectorAll("[data-navigate]").forEach(el => { el.addEventListener("click", () => { this.#dispatch(el.dataset.navigate!); }); }); // Tool chips 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); this.#dispatch(parsed?.moduleId || "rspace"); }); }); // Quick action buttons this.#shadow.querySelectorAll(".action-btn").forEach(el => { el.addEventListener("click", () => { this.#dispatch((el as HTMLElement).dataset.actionModule!); }); }); } #moveWidget(id: string, direction: number) { const sorted = [...this.#widgetConfigs].sort((a, b) => a.order - b.order); const idx = sorted.findIndex(c => c.id === id); if (idx < 0) return; const targetIdx = idx + direction; if (targetIdx < 0 || targetIdx >= sorted.length) return; // Swap order values const tmp = sorted[idx].order; sorted[idx].order = sorted[targetIdx].order; sorted[targetIdx].order = tmp; this.#saveWidgetConfigs(); this.#render(); } 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: 16px; } /* ── Space header ── */ .space-header { display: flex; align-items: center; gap: 16px; } .space-header__info { flex: 1; min-width: 0; } .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; } .customize-btn { flex-shrink: 0; background: none; border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); color: var(--rs-text-muted, #94a3b8); border-radius: 8px; padding: 8px; cursor: pointer; transition: color 0.15s, border-color 0.15s; } .customize-btn:hover { color: var(--rs-text-primary, #e2e8f0); border-color: var(--rs-accent, #06b6d4); } /* ── Widget cards ���─ */ .widget-card { background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: 16px; } .widget-card__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .widget-card__title { font-size: 0.85rem; font-weight: 600; color: var(--rs-text-secondary, #cbd5e1); } .widget-card__count { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: rgba(20,184,166,0.15); color: #14b8a6; font-weight: 600; } /* ── Widget list (tasks, calendar, flows, wallet) ── */ .widget-list { display: flex; flex-direction: column; } .widget-list-item { display: flex; align-items: center; gap: 8px; padding: 8px 4px; cursor: pointer; transition: background 0.15s; border: none; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.04)); background: none; text-align: left; color: inherit; font: inherit; width: 100%; } .widget-list-item:last-child { border-bottom: none; } .widget-list-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); } .widget-list-text { flex: 1; font-size: 0.8rem; color: var(--rs-text-primary, #e2e8f0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .widget-list-meta { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); flex-shrink: 0; white-space: nowrap; } .priority-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } /* ── Members (compact row) ── */ .members-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .member-circle { 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.75rem; font-weight: 700; flex-shrink: 0; border: none; cursor: pointer; transition: transform 0.15s; } .member-circle:hover { transform: scale(1.1); } .member-count { font-size: 0.7rem; color: var(--rs-text-muted, #94a3b8); margin-left: 4px; } /* ── Tools grid ── */ .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: rgba(255,255,255,0.04); 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; } /* ── Activity list ── */ .activity-list { display: flex; flex-direction: column; } .activity-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 8px 4px; cursor: pointer; transition: background 0.15s; border: none; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.04)); 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.04)); } .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; } /* ── 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: rgba(255,255,255,0.04); 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; } .section-empty { padding: 16px 8px; text-align: center; color: var(--rs-text-muted, #94a3b8); font-size: 0.78rem; } /* ─��� Customize panel ── */ .customize-panel { background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: 16px; } .customize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 0.85rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); } .customize-done { padding: 4px 12px; border-radius: 6px; background: rgba(20,184,166,0.15); color: #14b8a6; border: none; font-size: 0.78rem; font-weight: 600; cursor: pointer; } .customize-done:hover { background: rgba(20,184,166,0.25); } .customize-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.04)); } .customize-row:last-child { border-bottom: none; } .customize-toggle { display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: var(--rs-text-primary, #e2e8f0); cursor: pointer; } .customize-toggle input[type="checkbox"] { accent-color: #14b8a6; width: 16px; height: 16px; } .customize-arrows { display: flex; gap: 4px; } .customize-arrow { background: none; border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); color: var(--rs-text-muted, #94a3b8); border-radius: 4px; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0.6rem; transition: color 0.15s, border-color 0.15s; } .customize-arrow:hover { color: var(--rs-text-primary, #e2e8f0); border-color: var(--rs-accent, #06b6d4); } .customize-arrow-placeholder { width: 24px; height: 24px; } /* ── Responsive ── */ @media (max-width: 640px) { :host { position: static; overflow-y: visible; } .dashboard { padding: 20px 12px; } } @media (max-width: 480px) { .dashboard { padding: 16px 8px; } .actions-row { flex-direction: column; } .action-btn { justify-content: center; } } `;