rspace-online/shared/components/rstack-user-dashboard.ts

611 lines
14 KiB
TypeScript

/**
* <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;
}
}
`;