789 lines
19 KiB
TypeScript
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; }
|
|
}
|
|
`;
|