From 1568a5d0dc8d32653d2f558e597a5472adc7424e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 20:55:39 -0700 Subject: [PATCH] feat: user dashboard shown when all tabs are closed When the last tab is closed, a dashboard appears showing the user's spaces (sorted by most recent visit), notifications, and quick actions. Clicking any item creates a new tab and hides the dashboard. Browser back/forward handles dashboard state correctly. Also adds proper cache headers for HTML and Vite-hashed assets. Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 6 + server/shell.ts | 49 +- shared/components/rstack-tab-bar.ts | 20 +- shared/components/rstack-user-dashboard.ts | 610 +++++++++++++++++++++ shared/tab-cache.ts | 14 +- website/shell.ts | 12 + 6 files changed, 705 insertions(+), 6 deletions(-) create mode 100644 shared/components/rstack-user-dashboard.ts diff --git a/server/index.ts b/server/index.ts index 92382cd..8f3d026 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2062,6 +2062,12 @@ async function serveStatic(path: string, url?: URL): Promise { const headers: Record = { "Content-Type": getContentType(path) }; if (url?.searchParams.has("v")) { headers["Cache-Control"] = "public, max-age=31536000, immutable"; + } else if (path.endsWith(".html")) { + // HTML must revalidate so browsers pick up new hashed JS/CSS references + headers["Cache-Control"] = "no-cache"; + } else if (path.startsWith("assets/")) { + // Vite content-hashed assets are safe to cache long-term + headers["Cache-Control"] = "public, max-age=31536000, immutable"; } return new Response(file, { headers }); } diff --git a/server/shell.ts b/server/shell.ts index ecf6ac6..ceb07c6 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -146,6 +146,7 @@ export function renderShell(opts: ShellOptions): string {
+
${body}
@@ -438,8 +439,22 @@ export function renderShell(opts: ShellOptions): string { // Remove cached pane from DOM if (tabCache) tabCache.removePane(closedModuleId); - // If we closed the active tab, switch to the first remaining - if (layerId === 'layer-' + currentModuleId && layers.length > 0) { + + if (layers.length === 0) { + // No tabs left — show the dashboard + if (tabCache) tabCache.hideAllPanes(); + const dashboard = document.querySelector('rstack-user-dashboard'); + if (dashboard) { + dashboard.style.display = ''; + if (dashboard.refresh) dashboard.refresh(); + } + const app = document.getElementById('app'); + if (app) app.classList.remove('canvas-layout'); + tabBar.setAttribute('active', ''); + tabBar.setLayers([]); + history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug); + } else if (layerId === 'layer-' + currentModuleId) { + // Closed the active tab — switch to the first remaining const nextModuleId = layers[0].moduleId; if (tabCache) { tabCache.switchTo(nextModuleId).then(ok => { @@ -451,6 +466,36 @@ export function renderShell(opts: ShellOptions): string { } }); + // ── Dashboard navigate: user clicked a space/action on the dashboard ── + document.addEventListener('dashboard-navigate', (e) => { + const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail; + const dashboard = document.querySelector('rstack-user-dashboard'); + if (dashboard) dashboard.style.display = 'none'; + + // If navigating to a different space, do a full navigation + if (targetSpace && targetSpace !== spaceSlug) { + window.location.href = window.__rspaceNavUrl(targetSpace, targetModule || 'rspace'); + return; + } + + const modId = targetModule || 'rspace'; + // Add tab if not already present + if (!layers.find(l => l.moduleId === modId)) { + layers.push(makeLayer(modId, layers.length)); + } + saveTabs(); + tabBar.setLayers(layers); + tabBar.setAttribute('active', 'layer-' + modId); + + if (tabCache) { + tabCache.switchTo(modId).then(ok => { + if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, modId); + }); + } else { + window.location.href = window.__rspaceNavUrl(spaceSlug, modId); + } + }); + tabBar.addEventListener('view-toggle', (e) => { const { mode } = e.detail; document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } })); diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 9ba1259..aeaf88d 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -402,7 +402,15 @@ export class RStackTabBar extends HTMLElement {
- ${this.#layers.map(l => this.#renderTab(l, active)).join("")} + ${this.#layers.length === 0 + ? `
+ + + + + Dashboard +
` + : this.#layers.map(l => this.#renderTab(l, active)).join("")}
@@ -445,7 +453,7 @@ export class RStackTabBar extends HTMLElement { ${badge?.badge || layer.moduleId.slice(0, 2)} ${layer.label} ${spaceTag}${readOnlyTag} - ${this.#layers.length > 1 ? `` : ""} +
`; } @@ -1397,6 +1405,14 @@ const STYLES = ` color: var(--rs-text-primary); border-color: var(--rs-input-border); } +.tab--dashboard { + cursor: default; + pointer-events: none; + opacity: 0.8; +} +.tab--dashboard .tab-badge svg { + display: block; +} /* Active indicator line at bottom */ .tab-indicator { diff --git a/shared/components/rstack-user-dashboard.ts b/shared/components/rstack-user-dashboard.ts new file mode 100644 index 0000000..069eabc --- /dev/null +++ b/shared/components/rstack-user-dashboard.ts @@ -0,0 +1,610 @@ +/** + * — 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; + } +} +`; diff --git a/shared/tab-cache.ts b/shared/tab-cache.ts index 4a856dc..23f37ce 100644 --- a/shared/tab-cache.ts +++ b/shared/tab-cache.ts @@ -68,10 +68,20 @@ export class TabCache { // Handle browser back/forward window.addEventListener("popstate", (e) => { const state = e.state; + if (state?.dashboard) { + // Dashboard state — hide all panes and show dashboard + this.hideAllPanes(); + const dashboard = document.querySelector("rstack-user-dashboard"); + if (dashboard) (dashboard as HTMLElement).style.display = ""; + return; + } if (!state?.moduleId) { window.location.reload(); return; } + // If returning from dashboard, hide it + const dashboard = document.querySelector("rstack-user-dashboard"); + if (dashboard) (dashboard as HTMLElement).style.display = "none"; const stateSpace = state.spaceSlug || this.spaceSlug; const key = this.paneKey(stateSpace, state.moduleId); if (this.panes.has(key)) { @@ -330,8 +340,8 @@ export class TabCache { this.updateCanvasLayout(moduleId); } - /** Hide all panes */ - private hideAllPanes(): void { + /** Hide all panes (public so the shell can call it for dashboard) */ + hideAllPanes(): void { const app = document.getElementById("app"); if (!app) return; app.querySelectorAll(".rspace-tab-pane").forEach((p) => { diff --git a/website/shell.ts b/website/shell.ts index 1717c70..be451a0 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -16,6 +16,7 @@ import { RStackMi } from "../shared/components/rstack-mi"; import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"; import { RStackHistoryPanel } from "../shared/components/rstack-history-panel"; import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; +import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; import { rspaceNavUrl } from "../shared/url-helpers"; import { TabCache } from "../shared/tab-cache"; import { RSpaceOfflineRuntime } from "../shared/local-first/runtime"; @@ -36,6 +37,7 @@ RStackMi.define(); RStackSpaceSettings.define(); RStackHistoryPanel.define(); RStackOfflineIndicator.define(); +RStackUserDashboard.define(); // ── Offline Runtime ── // Instantiate the shared runtime from the space slug on the tag. @@ -69,6 +71,16 @@ if (spaceSlug && spaceSlug !== "demo") { }); } +// ── Track space visits for dashboard recency sorting ── +if (spaceSlug) { + try { + const RECENT_KEY = "rspace_recent_spaces"; + const visits = JSON.parse(localStorage.getItem(RECENT_KEY) || "{}"); + visits[spaceSlug] = Date.now(); + localStorage.setItem(RECENT_KEY, JSON.stringify(visits)); + } catch { /* localStorage unavailable */ } +} + // Reload space list when user signs in/out (to show/hide private spaces) document.addEventListener("auth-change", () => { const spaceSwitcher = document.querySelector("rstack-space-switcher") as any;