/** * — Shown when all tabs are closed. * * Displays a spaces grid (sorted by most recent visit), notifications panel, * and quick-action buttons. Dispatches `dashboard-navigate` events to open * rApps in new tabs. * * Attributes: * space — current space slug (for context) */ import { getSession, getAccessToken } from "./rstack-identity"; interface SpaceInfo { slug: string; name: string; icon?: string; role?: string; visibility?: string; relationship?: string; createdAt?: string; } interface NotificationItem { id: string; category: string; title: string; body: string | null; actionUrl: string | null; createdAt: string; read: boolean; } const RECENT_KEY = "rspace_recent_spaces"; export class RStackUserDashboard extends HTMLElement { #shadow: ShadowRoot; #spaces: SpaceInfo[] = []; #notifications: NotificationItem[] = []; #loading = true; #notifLoading = true; 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() { this.#render(); } /** Reload data when dashboard becomes visible again */ refresh() { this.#loading = true; this.#notifLoading = true; this.#render(); this.#loadData(); } async #loadData() { await Promise.all([this.#fetchSpaces(), this.#fetchNotifications()]); } async #fetchSpaces() { this.#loading = true; try { const headers: Record = {}; const token = getAccessToken(); if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch("/api/spaces", { headers }); if (res.ok) { const data = await res.json(); this.#spaces = data.spaces || []; } } catch { // Offline or API unavailable } this.#loading = 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 { // Silently fail } this.#notifLoading = false; this.#render(); } async #markAllRead() { const token = getAccessToken(); if (!token) return; try { await fetch("/api/notifications/read-all", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: "{}", }); } catch { /* best-effort */ } this.#notifications.forEach(n => n.read = true); this.#render(); } #getRecentVisits(): Record { try { const raw = localStorage.getItem(RECENT_KEY); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } #getSortedSpaces(): SpaceInfo[] { const visits = this.#getRecentVisits(); return [...this.#spaces].sort((a, b) => { const va = visits[a.slug] || 0; const vb = visits[b.slug] || 0; if (va !== vb) return vb - va; // most recent first // Fall back to createdAt const ca = a.createdAt ? new Date(a.createdAt).getTime() : 0; const cb = b.createdAt ? new Date(b.createdAt).getTime() : 0; return cb - ca; }); } #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`; } #spaceInitial(space: SpaceInfo): string { if (space.icon) return space.icon; return (space.name || space.slug).charAt(0).toUpperCase(); } #roleBadge(role?: string): string { if (!role || role === "owner") return ""; if (role === "admin") return `admin`; if (role === "member") return `member`; if (role === "viewer") return `view`; return `${role}`; } #dispatch(moduleId: string, spaceSlug?: string) { this.dispatchEvent(new CustomEvent("dashboard-navigate", { bubbles: true, detail: { moduleId, spaceSlug: spaceSlug || this.space }, })); } #parseActionUrl(url: string | null): { moduleId: string; spaceSlug?: string } | null { if (!url) return null; try { // Handle relative URLs like /myspace/rnotes or /rnotes 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; } #render() { const session = getSession(); const spaces = this.#getSortedSpaces(); const visits = this.#getRecentVisits(); const unreadCount = this.#notifications.filter(n => !n.read).length; // ── Spaces grid ── let spacesHTML: string; if (this.#loading) { spacesHTML = `
Loading spaces...
`; } else if (spaces.length === 0) { spacesHTML = `
No spaces found
`; } else { spacesHTML = spaces.map(s => { const lastVisit = visits[s.slug]; const timeLabel = lastVisit ? this.#timeAgo(new Date(lastVisit).toISOString()) : ""; return ` `; }).join(""); } // ── Notifications ── let notifsHTML: string; if (!session) { notifsHTML = `
Sign in to see notifications
`; } else if (this.#notifLoading) { notifsHTML = `
Loading...
`; } else if (this.#notifications.length === 0) { notifsHTML = `
No notifications
`; } else { notifsHTML = this.#notifications.map(n => ` `).join(""); } this.#shadow.innerHTML = `

Your Spaces

${spacesHTML}

Notifications ${unreadCount > 0 ? `${unreadCount}` : ""}

${unreadCount > 0 ? `` : ""}
${notifsHTML}

Quick Actions

`; this.#attachEvents(); } #attachEvents() { // Space cards this.#shadow.querySelectorAll(".space-card").forEach(el => { el.addEventListener("click", () => { const space = (el as HTMLElement).dataset.space!; const mod = (el as HTMLElement).dataset.module || "rspace"; this.#dispatch(mod, space); }); }); // Notification items this.#shadow.querySelectorAll(".notif-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, parsed.spaceSlug); } else { // Default: open canvas in current space this.#dispatch("rspace"); } }); }); // Mark all read this.#shadow.getElementById("mark-all-read")?.addEventListener("click", (e) => { e.stopPropagation(); this.#markAllRead(); }); // Quick action buttons this.#shadow.querySelectorAll(".action-btn").forEach(el => { el.addEventListener("click", () => { const mod = (el as HTMLElement).dataset.actionModule!; this.#dispatch(mod); }); }); } static define(tag = "rstack-user-dashboard") { if (!customElements.get(tag)) customElements.define(tag, RStackUserDashboard); } } // ============================================================================ // STYLES // ============================================================================ const STYLES = ` :host { display: block; width: 100%; height: 100%; } .dashboard { min-height: 100%; padding: 32px 24px; overflow-y: auto; background: var(--rs-bg, #0f172a); } .dashboard-inner { max-width: 720px; margin: 0 auto; display: flex; flex-direction: column; gap: 32px; } /* ── Sections ── */ .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .section-header h2 { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); display: flex; align-items: center; gap: 8px; } .section-empty { padding: 24px 16px; text-align: center; color: var(--rs-text-muted, #94a3b8); font-size: 0.8rem; } .unread-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: #ef4444; color: white; font-size: 0.7rem; font-weight: 700; } .mark-all-btn { background: none; border: none; color: var(--rs-accent, #06b6d4); font-size: 0.75rem; cursor: pointer; padding: 4px 8px; border-radius: 4px; transition: background 0.15s; } .mark-all-btn:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } /* ── Spaces grid ── */ .spaces-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; } .space-card { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 10px; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border, rgba(255,255,255,0.08)); cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.1s; text-align: left; color: inherit; font: inherit; } .space-card:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.06)); border-color: var(--rs-accent, #06b6d4); transform: translateY(-1px); } .space-icon { width: 40px; height: 40px; border-radius: 10px; background: linear-gradient(135deg, rgba(6,182,212,0.15), rgba(94,234,212,0.1)); color: var(--rs-accent, #06b6d4); display: flex; align-items: center; justify-content: center; font-size: 1.1rem; font-weight: 700; flex-shrink: 0; } .space-info { min-width: 0; flex: 1; } .space-name { font-size: 0.85rem; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .space-meta { display: flex; align-items: center; gap: 6px; margin-top: 3px; } .space-time { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); } .role-badge { font-size: 0.65rem; padding: 1px 5px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.02em; } .role-admin { background: rgba(251,191,36,0.15); color: #fbbf24; } .role-member { background: rgba(96,165,250,0.15); color: #60a5fa; } .role-viewer { background: rgba(148,163,184,0.15); color: #94a3b8; } /* ── Notifications ── */ .notifs-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; } .notif-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 16px; cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06)); background: none; border-left: none; border-right: none; border-top: none; text-align: left; color: inherit; font: inherit; width: 100%; } .notif-item:last-child { border-bottom: none; } .notif-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } .notif-item.unread { background: rgba(6,182,212,0.04); } .notif-item.unread .notif-title { font-weight: 600; } .notif-content { flex: 1; min-width: 0; } .notif-title { font-size: 0.8rem; color: var(--rs-text-primary, #e2e8f0); line-height: 1.3; } .notif-body { font-size: 0.75rem; color: var(--rs-text-muted, #94a3b8); margin-top: 2px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .notif-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: 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: 480px) { .dashboard { padding: 20px 12px; } .spaces-grid { grid-template-columns: 1fr; } } `;