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

789 lines
19 KiB
TypeScript

/**
* <rstack-user-dashboard> — Space-centric dashboard shown when all tabs are closed.
*
* Sections: space header + stats, members, tools open, recent activity,
* active votes, and quick actions.
*
* Attributes:
* space — current space slug
*/
import { getSession, getAccessToken } from "./rstack-identity";
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 ProposalInfo {
id: string;
title: string;
status: string;
score: number;
vote_count: string;
created_at: string;
}
interface TabLayer {
moduleId: string;
label?: string;
icon?: string;
}
const CACHE_TTL = 30_000; // 30s
export class RStackUserDashboard extends HTMLElement {
#shadow: ShadowRoot;
#members: MemberInfo[] = [];
#notifications: NotificationItem[] = [];
#proposals: ProposalInfo[] = [];
#openTabs: TabLayer[] = [];
#membersLoading = true;
#notifLoading = true;
#proposalsLoading = true;
#lastFetch = 0;
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() {
// Space changed — reset cache
this.#lastFetch = 0;
this.#render();
}
/** Called from shell.ts before tabs are cleared */
setOpenTabs(tabs: TabLayer[]) {
this.#openTabs = tabs;
}
/** Reload data when dashboard becomes visible again */
refresh() {
if (Date.now() - this.#lastFetch < CACHE_TTL) {
this.#render();
return;
}
this.#membersLoading = true;
this.#notifLoading = true;
this.#proposalsLoading = true;
this.#render();
this.#loadData();
}
async #loadData() {
this.#lastFetch = Date.now();
await Promise.all([
this.#fetchMembers(),
this.#fetchNotifications(),
this.#fetchProposals(),
]);
}
async #fetchMembers() {
this.#membersLoading = true;
try {
const res = await fetch(`/${encodeURIComponent(this.space)}/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=10", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
this.#notifications = data.notifications || [];
}
} catch { /* offline */ }
this.#notifLoading = false;
this.#render();
}
async #fetchProposals() {
this.#proposalsLoading = true;
try {
const slug = encodeURIComponent(this.space);
const res = await fetch(`/${slug}/rvote/api/proposals?space_slug=${slug}&limit=5`);
if (res.ok) {
const data = await res.json();
this.#proposals = (data.proposals || []).filter(
(p: ProposalInfo) => p.status !== "completed" && p.status !== "rejected",
);
}
} catch {
// rvote not installed or offline — hide section
this.#proposals = [];
}
this.#proposalsLoading = false;
this.#render();
}
#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`;
}
#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();
}
// Module icons for "tools open" chips
#moduleIcon(moduleId: string): string {
const icons: Record<string, string> = {
rspace: "\u{1F30C}", rnotes: "\u{1F4DD}", rmeets: "\u{1F4F9}", rvote: "\u{1F5F3}",
rnetwork: "\u{1F465}", rcalendar: "\u{1F4C5}", rtasks: "\u{2705}",
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;
// Capitalize moduleId
const id = tab.moduleId;
return id.charAt(0).toUpperCase() + id.slice(1);
}
#render() {
const session = getSession();
const space = this.space;
const spaceName = space.charAt(0).toUpperCase() + space.slice(1);
// ── Stats pills ──
const statPills: string[] = [];
if (!this.#membersLoading) {
statPills.push(`<span class="stat-pill">${this.#members.length} member${this.#members.length !== 1 ? "s" : ""}</span>`);
}
if (!this.#proposalsLoading && this.#proposals.length > 0) {
statPills.push(`<span class="stat-pill">${this.#proposals.length} active proposal${this.#proposals.length !== 1 ? "s" : ""}</span>`);
}
if (this.#openTabs.length > 0) {
statPills.push(`<span class="stat-pill">${this.#openTabs.length} tab${this.#openTabs.length !== 1 ? "s" : ""} open</span>`);
}
// ── Members ──
let membersHTML: string;
if (this.#membersLoading) {
membersHTML = `<div class="section-empty">Loading members...</div>`;
} else if (this.#members.length === 0) {
membersHTML = `<div class="section-empty">No members found</div>`;
} else {
membersHTML = this.#members.map(m => `
<button class="member-card" data-navigate="rnetwork">
<div class="member-avatar">${this.#memberInitial(m)}</div>
<div class="member-info">
<div class="member-name">${m.displayName || m.username}</div>
${m.username && m.displayName ? `<div class="member-handle">@${m.username}</div>` : ""}
</div>
</button>
`).join("");
}
// ── Tools open ──
let toolsHTML: string;
if (this.#openTabs.length === 0) {
toolsHTML = `<div class="section-empty">No tools were open</div>`;
} else {
toolsHTML = this.#openTabs.map(t => `
<button class="tool-chip" data-module="${t.moduleId}">
<span class="tool-icon">${this.#moduleIcon(t.moduleId)}</span>
<span>${this.#moduleLabel(t)}</span>
</button>
`).join("");
}
// ── Notifications / Recent Activity ──
let activityHTML: string;
if (!session) {
activityHTML = `<div class="section-empty">Sign in to see recent activity</div>`;
} else if (this.#notifLoading) {
activityHTML = `<div class="section-empty">Loading...</div>`;
} else if (this.#notifications.length === 0) {
activityHTML = `<div class="section-empty">No recent activity</div>`;
} else {
activityHTML = this.#notifications.map(n => `
<button class="activity-item ${n.read ? "read" : "unread"}" data-action-url="${n.actionUrl || ""}">
<div class="activity-content">
<div class="activity-title">${n.title}</div>
${n.body ? `<div class="activity-body">${n.body}</div>` : ""}
</div>
<span class="activity-time">${this.#timeAgo(n.createdAt)}</span>
</button>
`).join("");
}
// ── Active Votes ──
let votesHTML = "";
if (!this.#proposalsLoading && this.#proposals.length > 0) {
votesHTML = `
<div class="section">
<div class="section-header"><h2>Active Votes</h2></div>
<div class="votes-list">
${this.#proposals.map(p => `
<button class="vote-item" data-navigate="rvote">
<div class="vote-info">
<div class="vote-title">${p.title}</div>
<div class="vote-meta">
<span class="vote-status">${p.status}</span>
<span class="vote-count">${p.vote_count} vote${p.vote_count !== "1" ? "s" : ""}</span>
</div>
</div>
<span class="vote-score">${p.score > 0 ? "+" : ""}${p.score}</span>
</button>
`).join("")}
</div>
</div>
`;
}
// ── Quick Actions ──
const hasProposals = this.#proposals.length > 0;
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="dashboard">
<div class="dashboard-inner">
<div class="space-header">
<div class="space-icon">${spaceName.charAt(0)}</div>
<div>
<h1 class="space-title">${spaceName}</h1>
${statPills.length > 0 ? `<div class="stat-row">${statPills.join("")}</div>` : ""}
</div>
</div>
<div class="two-col">
<div class="section">
<div class="section-header"><h2>Members</h2></div>
<div class="members-list">${membersHTML}</div>
</div>
<div class="section">
<div class="section-header"><h2>Tools Open</h2></div>
<div class="tools-grid">${toolsHTML}</div>
</div>
</div>
<div class="section">
<div class="section-header"><h2>Recent Activity</h2></div>
<div class="activity-list">${activityHTML}</div>
</div>
${votesHTML}
<div class="section">
<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">\u{1F4F9}</span><span>Quick Meet</span>
</button>
<button class="action-btn" data-action-module="rnotes">
<span class="action-icon">\u{1F4DD}</span><span>New Note</span>
</button>
<button class="action-btn" data-action-module="rspace">
<span class="action-icon">\u{1F30C}</span><span>Canvas</span>
</button>
${hasProposals ? `
<button class="action-btn" data-action-module="rvote">
<span class="action-icon">\u{1F5F3}</span><span>Vote</span>
</button>
` : ""}
</div>
</div>
</div>
</div>
`;
this.#attachEvents();
}
#attachEvents() {
// Member cards → open rNetwork
this.#shadow.querySelectorAll(".member-card").forEach(el => {
el.addEventListener("click", () => {
this.#dispatch((el as HTMLElement).dataset.navigate || "rnetwork");
});
});
// Tool chips → re-open tab
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);
if (parsed) {
this.#dispatch(parsed.moduleId);
} else {
this.#dispatch("rspace");
}
});
});
// Vote items
this.#shadow.querySelectorAll(".vote-item").forEach(el => {
el.addEventListener("click", () => {
this.#dispatch((el as HTMLElement).dataset.navigate || "rvote");
});
});
// Quick action buttons
this.#shadow.querySelectorAll(".action-btn").forEach(el => {
el.addEventListener("click", () => {
this.#dispatch((el as HTMLElement).dataset.actionModule!);
});
});
}
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: 28px;
}
/* ── Space header ── */
.space-header {
display: flex;
align-items: center;
gap: 16px;
}
.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;
}
/* ── Two-column grid ── */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
/* ── Sections ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-header h2 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--rs-text-secondary, #cbd5e1);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section-empty {
padding: 20px 16px;
text-align: center;
color: var(--rs-text-muted, #94a3b8);
font-size: 0.78rem;
}
/* ── Members ── */
.members-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;
}
.member-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.15s;
border: none;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
background: none;
text-align: left;
color: inherit;
font: inherit;
width: 100%;
}
.member-card:last-child { border-bottom: none; }
.member-card:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
}
.member-avatar {
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.8rem;
font-weight: 700;
flex-shrink: 0;
}
.member-info { min-width: 0; }
.member-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--rs-text-primary, #e2e8f0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-handle {
font-size: 0.7rem;
color: var(--rs-text-muted, #64748b);
}
/* ── Tools open ── */
.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: 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.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; }
/* ── Recent Activity ── */
.activity-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;
}
.activity-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s;
border: none;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
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.05));
}
.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;
}
/* ── Active Votes ── */
.votes-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;
}
.vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s;
border: none;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
background: none;
text-align: left;
color: inherit;
font: inherit;
width: 100%;
}
.vote-item:last-child { border-bottom: none; }
.vote-item:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
}
.vote-info { flex: 1; min-width: 0; }
.vote-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--rs-text-primary, #e2e8f0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vote-meta {
display: flex;
gap: 8px;
margin-top: 3px;
}
.vote-status {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 4px;
background: rgba(251,191,36,0.15);
color: #fbbf24;
font-weight: 600;
text-transform: uppercase;
}
.vote-count {
font-size: 0.7rem;
color: var(--rs-text-muted, #94a3b8);
}
.vote-score {
font-size: 0.85rem;
font-weight: 700;
color: var(--rs-accent, #06b6d4);
flex-shrink: 0;
}
/* ── 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: 640px) {
:host {
position: static;
overflow-y: visible;
}
.dashboard { padding: 20px 12px; }
.two-col { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.dashboard { padding: 16px 8px; }
.actions-row { flex-direction: column; }
.action-btn { justify-content: center; }
}
`;