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 <noreply@anthropic.com>
This commit is contained in:
parent
bee09b0e43
commit
1568a5d0dc
|
|
@ -2062,6 +2062,12 @@ async function serveStatic(path: string, url?: URL): Promise<Response | null> {
|
|||
const headers: Record<string, string> = { "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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<div class="rstack-tab-row">
|
||||
<rstack-tab-bar space="${escapeAttr(spaceSlug)}" active="" view-mode="flat"></rstack-tab-bar>
|
||||
</div>
|
||||
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
|
||||
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
|
||||
${body}
|
||||
</main>
|
||||
|
|
@ -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 } }));
|
||||
|
|
|
|||
|
|
@ -402,7 +402,15 @@ export class RStackTabBar extends HTMLElement {
|
|||
<style>${STYLES}</style>
|
||||
<div class="tab-bar" data-view="${this.#viewMode}">
|
||||
<div class="tabs-scroll">
|
||||
${this.#layers.map(l => this.#renderTab(l, active)).join("")}
|
||||
${this.#layers.length === 0
|
||||
? `<div class="tab active tab--dashboard">
|
||||
<span class="tab-indicator" style="background:#5eead4"></span>
|
||||
<span class="tab-badge" style="background:#5eead4">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
|
||||
</span>
|
||||
<span class="tab-label">Dashboard</span>
|
||||
</div>`
|
||||
: this.#layers.map(l => this.#renderTab(l, active)).join("")}
|
||||
</div>
|
||||
<div class="tab-add-wrap">
|
||||
<button class="tab-add" id="add-btn" title="Add layer">+</button>
|
||||
|
|
@ -445,7 +453,7 @@ export class RStackTabBar extends HTMLElement {
|
|||
<span class="tab-badge" style="background:${badgeColor}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
|
||||
<span class="tab-label">${layer.label}</span>
|
||||
${spaceTag}${readOnlyTag}
|
||||
${this.#layers.length > 1 ? `<button class="tab-close" data-close="${layer.id}">×</button>` : ""}
|
||||
<button class="tab-close" data-close="${layer.id}">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,610 @@
|
|||
/**
|
||||
* <rstack-user-dashboard> — 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<string, string> = {};
|
||||
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<string, number> {
|
||||
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 `<span class="role-badge role-admin">admin</span>`;
|
||||
if (role === "member") return `<span class="role-badge role-member">member</span>`;
|
||||
if (role === "viewer") return `<span class="role-badge role-viewer">view</span>`;
|
||||
return `<span class="role-badge">${role}</span>`;
|
||||
}
|
||||
|
||||
#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 = `<div class="section-empty">Loading spaces...</div>`;
|
||||
} else if (spaces.length === 0) {
|
||||
spacesHTML = `<div class="section-empty">No spaces found</div>`;
|
||||
} else {
|
||||
spacesHTML = spaces.map(s => {
|
||||
const lastVisit = visits[s.slug];
|
||||
const timeLabel = lastVisit ? this.#timeAgo(new Date(lastVisit).toISOString()) : "";
|
||||
return `
|
||||
<button class="space-card" data-space="${s.slug}" data-module="rspace">
|
||||
<div class="space-icon">${this.#spaceInitial(s)}</div>
|
||||
<div class="space-info">
|
||||
<div class="space-name">${s.name || s.slug}</div>
|
||||
<div class="space-meta">
|
||||
${this.#roleBadge(s.role)}
|
||||
${timeLabel ? `<span class="space-time">${timeLabel}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// ── Notifications ──
|
||||
let notifsHTML: string;
|
||||
if (!session) {
|
||||
notifsHTML = `<div class="section-empty">Sign in to see notifications</div>`;
|
||||
} else if (this.#notifLoading) {
|
||||
notifsHTML = `<div class="section-empty">Loading...</div>`;
|
||||
} else if (this.#notifications.length === 0) {
|
||||
notifsHTML = `<div class="section-empty">No notifications</div>`;
|
||||
} else {
|
||||
notifsHTML = this.#notifications.map(n => `
|
||||
<button class="notif-item ${n.read ? "read" : "unread"}" data-action-url="${n.actionUrl || ""}">
|
||||
<div class="notif-content">
|
||||
<div class="notif-title">${n.title}</div>
|
||||
${n.body ? `<div class="notif-body">${n.body}</div>` : ""}
|
||||
</div>
|
||||
<span class="notif-time">${this.#timeAgo(n.createdAt)}</span>
|
||||
</button>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-inner">
|
||||
<div class="section section--spaces">
|
||||
<div class="section-header">
|
||||
<h2>Your Spaces</h2>
|
||||
</div>
|
||||
<div class="spaces-grid">${spacesHTML}</div>
|
||||
</div>
|
||||
|
||||
<div class="section section--notifications">
|
||||
<div class="section-header">
|
||||
<h2>Notifications ${unreadCount > 0 ? `<span class="unread-badge">${unreadCount}</span>` : ""}</h2>
|
||||
${unreadCount > 0 ? `<button class="mark-all-btn" id="mark-all-read">Mark all read</button>` : ""}
|
||||
</div>
|
||||
<div class="notifs-list">${notifsHTML}</div>
|
||||
</div>
|
||||
|
||||
<div class="section section--actions">
|
||||
<div class="section-header"><h2>Quick Actions</h2></div>
|
||||
<div class="actions-row">
|
||||
<button class="action-btn" data-action-module="rmeets">
|
||||
<span class="action-icon">📹</span>
|
||||
<span>Quick Meet</span>
|
||||
</button>
|
||||
<button class="action-btn" data-action-module="rnotes">
|
||||
<span class="action-icon">📝</span>
|
||||
<span>New Note</span>
|
||||
</button>
|
||||
<button class="action-btn" data-action-module="rspace">
|
||||
<span class="action-icon">🌌</span>
|
||||
<span>Canvas</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 <body> 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue