feat: add admin dashboard at /admin with space overview
Adds a new /admin page showing all spaces with stats (shape count, member count, file size, visibility), search/filter/sort controls, and links to open or export each space. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b2f1bebbd2
commit
033f4a3d92
|
|
@ -328,6 +328,15 @@ app.get("/create-space", async (c) => {
|
|||
// Legacy redirect
|
||||
app.get("/new", (c) => c.redirect("/create-space", 301));
|
||||
|
||||
// Admin dashboard
|
||||
app.get("/admin", async (c) => {
|
||||
const file = Bun.file(resolve(DIST_DIR, "admin.html"));
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: { "Content-Type": "text/html" } });
|
||||
}
|
||||
return c.text("Admin", 200);
|
||||
});
|
||||
|
||||
// Space root: /:space → redirect to /:space/canvas
|
||||
app.get("/:space", (c) => {
|
||||
const space = c.req.param("space");
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { stat } from "node:fs/promises";
|
||||
import {
|
||||
communityExists,
|
||||
createCommunity,
|
||||
|
|
@ -149,4 +150,63 @@ spaces.get("/:slug", async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Admin: list ALL spaces with detailed stats ──
|
||||
|
||||
spaces.get("/admin", async (c) => {
|
||||
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
|
||||
const slugs = await listCommunities();
|
||||
|
||||
const spacesList = [];
|
||||
for (const slug of slugs) {
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
if (!data?.meta) continue;
|
||||
|
||||
const shapes = data.shapes || {};
|
||||
const members = data.members || {};
|
||||
const shapeCount = Object.keys(shapes).length;
|
||||
const memberCount = Object.keys(members).length;
|
||||
|
||||
// Get file size on disk
|
||||
let fileSizeBytes = 0;
|
||||
try {
|
||||
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
|
||||
fileSizeBytes = s.size;
|
||||
} catch {
|
||||
try {
|
||||
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
|
||||
fileSizeBytes = s.size;
|
||||
} catch { /* not on disk yet */ }
|
||||
}
|
||||
|
||||
// Count shapes by type
|
||||
const shapeTypes: Record<string, number> = {};
|
||||
for (const shape of Object.values(shapes)) {
|
||||
const t = (shape as Record<string, unknown>).type as string || "unknown";
|
||||
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
|
||||
}
|
||||
|
||||
spacesList.push({
|
||||
slug: data.meta.slug,
|
||||
name: data.meta.name,
|
||||
visibility: data.meta.visibility || "public_read",
|
||||
createdAt: data.meta.createdAt,
|
||||
ownerDID: data.meta.ownerDID,
|
||||
shapeCount,
|
||||
memberCount,
|
||||
fileSizeBytes,
|
||||
shapeTypes,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by creation date descending (newest first)
|
||||
spacesList.sort((a, b) => {
|
||||
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
|
||||
return c.json({ spaces: spacesList, total: spacesList.length });
|
||||
});
|
||||
|
||||
export { spaces };
|
||||
|
|
|
|||
|
|
@ -717,6 +717,7 @@ export default defineConfig({
|
|||
index: resolve(__dirname, "./website/index.html"),
|
||||
canvas: resolve(__dirname, "./website/canvas.html"),
|
||||
"create-space": resolve(__dirname, "./website/create-space.html"),
|
||||
admin: resolve(__dirname, "./website/admin.html"),
|
||||
},
|
||||
},
|
||||
modulePreload: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,617 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
<title>Admin Dashboard — rSpace</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.admin-container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 2rem;
|
||||
background: linear-gradient(135deg, #14b8a6, #22d3ee);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #14b8a6;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: #14b8a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
border-color: #14b8a6;
|
||||
background: rgba(20, 184, 166, 0.15);
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-select option {
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sort-select:focus {
|
||||
outline: none;
|
||||
border-color: #14b8a6;
|
||||
}
|
||||
|
||||
.spaces-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.spaces-table thead {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.spaces-table th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spaces-table th:hover {
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.spaces-table th .sort-arrow {
|
||||
margin-left: 4px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.spaces-table th.sorted .sort-arrow {
|
||||
opacity: 1;
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.spaces-table td {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.9rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.spaces-table tbody tr {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.spaces-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.space-name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.space-name {
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.space-slug {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-public {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.badge-public_read {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.badge-authenticated {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.badge-members_only {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.num-cell {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.owner-cell {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-link:hover {
|
||||
color: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
background: rgba(20, 184, 166, 0.08);
|
||||
}
|
||||
|
||||
.shape-types {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shape-type-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.loading .spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(20, 184, 166, 0.3);
|
||||
border-top-color: #14b8a6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.spaces-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/shell.css">
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<header class="rstack-header" data-theme="dark">
|
||||
<div class="rstack-header__left">
|
||||
<rstack-app-switcher current=""></rstack-app-switcher>
|
||||
<rstack-space-switcher current="" name="Spaces"></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<a href="/" class="back-link">← Back to rSpace</a>
|
||||
</div>
|
||||
|
||||
<div class="stats-row" id="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-total">--</div>
|
||||
<div class="stat-label">Total Spaces</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-shapes">--</div>
|
||||
<div class="stat-label">Total Shapes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-members">--</div>
|
||||
<div class="stat-label">Total Members</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-storage">--</div>
|
||||
<div class="stat-label">Storage Used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<input type="text" class="search-input" id="search-input" placeholder="Search spaces..." />
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="public">Public</button>
|
||||
<button class="filter-btn" data-filter="public_read">Public Read</button>
|
||||
<button class="filter-btn" data-filter="authenticated">Auth</button>
|
||||
<button class="filter-btn" data-filter="members_only">Private</button>
|
||||
<select class="sort-select" id="sort-select">
|
||||
<option value="created-desc">Newest First</option>
|
||||
<option value="created-asc">Oldest First</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="shapes-desc">Most Shapes</option>
|
||||
<option value="size-desc">Largest Size</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="table-container">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading spaces...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { RStackIdentity } from "@shared/components/rstack-identity";
|
||||
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
|
||||
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";
|
||||
|
||||
RStackIdentity.define();
|
||||
RStackAppSwitcher.define();
|
||||
RStackSpaceSwitcher.define();
|
||||
|
||||
fetch("/api/modules").then(r => r.json()).then(data => {
|
||||
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
|
||||
}).catch(() => {});
|
||||
|
||||
let allSpaces = [];
|
||||
let currentFilter = "all";
|
||||
let currentSort = "created-desc";
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function truncateDID(did) {
|
||||
if (!did) return "—";
|
||||
if (did.length <= 16) return did;
|
||||
return did.slice(0, 8) + "..." + did.slice(-6);
|
||||
}
|
||||
|
||||
function visibilityLabel(v) {
|
||||
const labels = {
|
||||
public: "Public",
|
||||
public_read: "Public Read",
|
||||
authenticated: "Auth Required",
|
||||
members_only: "Members Only",
|
||||
};
|
||||
return labels[v] || v;
|
||||
}
|
||||
|
||||
function getFilteredSorted() {
|
||||
let list = allSpaces;
|
||||
|
||||
// Filter
|
||||
if (currentFilter !== "all") {
|
||||
list = list.filter(s => s.visibility === currentFilter);
|
||||
}
|
||||
|
||||
// Search
|
||||
const q = document.getElementById("search-input").value.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(s =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.slug.toLowerCase().includes(q) ||
|
||||
(s.ownerDID || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const [field, dir] = currentSort.split("-");
|
||||
list = [...list].sort((a, b) => {
|
||||
let va, vb;
|
||||
if (field === "created") {
|
||||
va = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
vb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
} else if (field === "name") {
|
||||
va = (a.name || "").toLowerCase();
|
||||
vb = (b.name || "").toLowerCase();
|
||||
} else if (field === "shapes") {
|
||||
va = a.shapeCount;
|
||||
vb = b.shapeCount;
|
||||
} else if (field === "size") {
|
||||
va = a.fileSizeBytes;
|
||||
vb = b.fileSizeBytes;
|
||||
}
|
||||
if (typeof va === "string") {
|
||||
return dir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
}
|
||||
return dir === "asc" ? va - vb : vb - va;
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const list = getFilteredSorted();
|
||||
const container = document.getElementById("table-container");
|
||||
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No spaces match your filters.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = list.map(s => {
|
||||
const topTypes = Object.entries(s.shapeTypes || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([t, c]) => `<span class="shape-type-tag">${t} (${c})</span>`)
|
||||
.join("");
|
||||
|
||||
return `<tr>
|
||||
<td>
|
||||
<div class="space-name-cell">
|
||||
<span class="space-name">${s.name}</span>
|
||||
<span class="space-slug">${s.slug}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="badge badge-${s.visibility}">${visibilityLabel(s.visibility)}</span></td>
|
||||
<td class="num-cell">${s.shapeCount}</td>
|
||||
<td class="num-cell">${s.memberCount}</td>
|
||||
<td class="num-cell">${formatBytes(s.fileSizeBytes)}</td>
|
||||
<td class="date-cell">${formatDate(s.createdAt)}</td>
|
||||
<td class="owner-cell" title="${s.ownerDID || ""}">${truncateDID(s.ownerDID)}</td>
|
||||
<td>
|
||||
<div class="actions-cell">
|
||||
<a href="/${s.slug}/canvas" class="action-link">Open</a>
|
||||
<a href="/api/communities/${s.slug}/shapes" class="action-link" target="_blank">JSON</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="spaces-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Space</th>
|
||||
<th>Visibility</th>
|
||||
<th style="text-align:right">Shapes</th>
|
||||
<th style="text-align:right">Members</th>
|
||||
<th style="text-align:right">Size</th>
|
||||
<th>Created</th>
|
||||
<th>Owner</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const totalShapes = allSpaces.reduce((sum, s) => sum + s.shapeCount, 0);
|
||||
const totalMembers = allSpaces.reduce((sum, s) => sum + s.memberCount, 0);
|
||||
const totalBytes = allSpaces.reduce((sum, s) => sum + s.fileSizeBytes, 0);
|
||||
|
||||
document.getElementById("stat-total").textContent = allSpaces.length;
|
||||
document.getElementById("stat-shapes").textContent = totalShapes.toLocaleString();
|
||||
document.getElementById("stat-members").textContent = totalMembers;
|
||||
document.getElementById("stat-storage").textContent = formatBytes(totalBytes);
|
||||
}
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll(".filter-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
currentFilter = btn.dataset.filter;
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
|
||||
// Sort select
|
||||
document.getElementById("sort-select").addEventListener("change", (e) => {
|
||||
currentSort = e.target.value;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
// Search
|
||||
document.getElementById("search-input").addEventListener("input", () => {
|
||||
renderTable();
|
||||
});
|
||||
|
||||
// Load data
|
||||
fetch("/api/spaces/admin")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
allSpaces = data.spaces || [];
|
||||
updateStats();
|
||||
renderTable();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById("table-container").innerHTML =
|
||||
`<div class="empty-state">Failed to load spaces: ${err.message}</div>`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue