/**
* Admin Dashboard — P2P status, user management, notebook management
*
* SSR view with inline scripts for interactive elements.
*/
import { renderPage } from '../page-layout.js'
function esc (s) {
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
/**
* Render the admin dashboard page
*
* @param {Object} data
* @param {Object} data.stats - { users, notebooks, notes, publicNotebooks, privateNotebooks }
* @param {Array} data.users - User list
* @param {Array} data.notebooks - Notebook list
* @param {Object} data.syncStatus - P2P sync status
* @param {Object} data.siteInfo - { siteKey, hyperUrl }
* @param {Object} data.mwStatus - Multiwriter status
* @param {Object} opts - { pathPrefix }
* @returns {string} Full HTML page
*/
export function renderAdminPage (data, opts = {}) {
const pathPrefix = opts.pathPrefix || ''
const { stats, users, notebooks, syncStatus, siteInfo, mwStatus } = data
let content = ''
// P2P Network
content += '<h2 class="admin-section-title">P2P Network</h2>'
const swarmActive = syncStatus && syncStatus.swarm && syncStatus.swarm.connected
const peerCount = (syncStatus && syncStatus.swarm && syncStatus.swarm.peerCount) || 0
content += `<div class="p2p-status-bar">
<span class="p2p-indicator ${swarmActive ? 'p2p-online' : 'p2p-offline'}"></span>
<span>${swarmActive ? 'Swarm active' : 'Swarm offline'}</span>
<span class="meta" style="margin-left:12px">${peerCount} peer${peerCount !== 1 ? 's' : ''} connected</span>
</div>`
// Site drive URL
if (siteInfo && siteInfo.hyperUrl) {
content += `<div class="p2p-site-row">
<label>Site Drive:</label>
<code class="copyable" data-copy="${esc(siteInfo.hyperUrl)}">${esc(siteInfo.hyperUrl)}</code>
<button class="btn btn-sm" id="btn-republish">Republish</button>
</div>`
}
// Per-notebook sharing table
content += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
content += '<th>Notebook</th><th>Drive Key</th><th>Sharing</th><th>Actions</th>'
content += '</tr></thead><tbody>'
for (const nb of notebooks) {
const driveKey = nb.driveKey || ''
const shortKey = driveKey ? driveKey.substring(0, 12) + '...' : 'none'
const announced = syncStatus && syncStatus.announced && syncStatus.announced.includes(nb.slug)
content += `<tr>
<td>${esc(nb.name || nb.slug)}</td>
<td><code class="copyable p2p-key" data-copy="${esc(driveKey)}">${esc(shortKey)}</code></td>
<td><span class="badge ${announced ? 'badge-success' : 'badge-secondary'}">${announced ? 'sharing' : 'local only'}</span></td>
<td>${announced
? `<button class="btn btn-sm p2p-action" data-action="unannounce" data-slug="${esc(nb.slug)}">Stop Sharing</button>`
: `<button class="btn btn-sm p2p-action" data-action="announce" data-slug="${esc(nb.slug)}">Share P2P</button>`
}</td>
</tr>`
}
content += '</tbody></table></div>'
// Add remote notebook
content += `<div class="p2p-add-remote">
<h4>Add Remote Notebook</h4>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" id="remote-key" placeholder="Drive key (64-char hex)" style="flex:1;min-width:200px">
<input type="text" id="remote-slug" placeholder="Local name (optional)" style="width:150px">
<button class="btn btn-sm btn-primary" id="btn-add-remote">Add</button>
</div>
</div>`
// Multi-writer sync
content += '<h2 class="admin-section-title">Multi-Writer Sync</h2>'
const linked = mwStatus && mwStatus.linked
const sharedCount = (mwStatus && mwStatus.sharedNotebooks) || 0
content += `<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<span class="badge ${linked ? 'badge-success' : 'badge-secondary'}">${linked ? 'Linked' : 'Not linked'}</span>
<span class="meta">${sharedCount} shared notebook${sharedCount !== 1 ? 's' : ''}</span>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-primary" id="btn-mw-link">Create Link Invite</button>
<button class="btn btn-sm" id="btn-mw-join">Join with Invite</button>
</div>
<div id="mw-invite" style="display:none;margin-top:12px">
<input type="text" id="mw-invite-code" readonly style="font-family:monospace;font-size:0.85em">
<button class="btn btn-sm" id="btn-mw-copy" style="margin-top:4px">Copy</button>
</div>`
// Stats cards
content += '<h2 class="admin-section-title">Statistics</h2>'
content += '<div class="stats-grid">'
const statItems = [
{ label: 'Users', value: stats.users || 0 },
{ label: 'Notebooks', value: stats.notebooks || 0 },
{ label: 'Notes', value: stats.notes || 0 },
{ label: 'Public', value: stats.publicNotebooks || 0 },
{ label: 'Private', value: stats.privateNotebooks || 0 }
]
for (const s of statItems) {
content += `<div class="stat-card"><div class="stat-value">${s.value}</div><div class="stat-label">${s.label}</div></div>`
}
content += '</div>'
// Users table
content += '<h2 class="admin-section-title">Users</h2>'
content += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
content += '<th>Name</th><th>Username</th><th>Role</th><th>Joined</th><th>Actions</th>'
content += '</tr></thead><tbody>'
for (const u of users) {
const userRole = (u.globalPermissions || []).includes('admin') ? 'admin'
: (u.globalPermissions || []).includes('write') ? 'writer' : 'reader'
const joined = u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '-'
content += `<tr data-userid="${esc(u.userId)}">
<td>${esc(u.name || 'Anonymous')}</td>
<td>${u.username ? '@' + esc(u.username) : '<span class="meta">none</span>'}</td>
<td><span class="badge badge-${userRole === 'admin' ? 'success' : 'secondary'}">${esc(userRole)}</span></td>
<td class="meta">${joined}</td>
<td>${userRole !== 'admin' ? `
<select class="role-select" data-userid="${esc(u.userId)}">
<option value="" disabled selected>Change role...</option>
<option value="read" ${userRole === 'reader' ? 'disabled' : ''}>Reader</option>
<option value="write" ${userRole === 'writer' ? 'disabled' : ''}>Writer</option>
<option value="admin">Admin</option>
</select>
<button class="btn btn-sm btn-danger delete-user" data-userid="${esc(u.userId)}" data-name="${esc(u.name || 'user')}">Delete</button>
` : '<span class="meta">—</span>'}</td>
</tr>`
}
content += '</tbody></table></div>'
// Notebooks table
content += '<h2 class="admin-section-title">Notebooks</h2>'
content += '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>'
content += '<th>Name</th><th>Slug</th><th>Owner</th><th>Visibility</th><th>Notes</th><th>Transfer</th>'
content += '</tr></thead><tbody>'
for (const nb of notebooks) {
const ownerUser = nb.ownerId ? users.find(u => u.userId === nb.ownerId) : null
const ownerName = ownerUser ? (ownerUser.username || ownerUser.name || 'Unknown') : 'unowned'
content += `<tr>
<td><a href="${pathPrefix}/notebooks/${encodeURIComponent(nb.slug)}">${esc(nb.name || nb.slug)}</a></td>
<td class="meta">${esc(nb.slug)}</td>
<td>${ownerUser ? esc(ownerName) : '<span class="meta">unowned</span>'}</td>
<td><span class="badge badge-secondary">${esc(nb.visibility || 'public')}</span></td>
<td>${nb.noteCount || 0}</td>
<td>
<select class="owner-select" data-slug="${esc(nb.slug)}">
<option value="" disabled selected>Transfer to...</option>
${users.map(u => `<option value="${esc(u.userId)}" ${u.userId === nb.ownerId ? 'disabled' : ''}>${esc(u.username || u.name || u.userId.substring(0, 8))}</option>`).join('')}
</select>
</td>
</tr>`
}
content += '</tbody></table></div>'
const css = `
.admin-section-title { margin-top:32px; margin-bottom:12px; font-size:1.1rem; }
.p2p-status-bar { display:flex; align-items:center; gap:8px; margin-bottom:16px; }
.p2p-indicator { width:10px; height:10px; border-radius:50%; }
.p2p-online { background:var(--success); }
.p2p-offline { background:var(--danger); }
.p2p-site-row { display:flex; align-items:center; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
.p2p-site-row label { font-size:0.85rem; color:var(--text-muted); }
.copyable { cursor:pointer; font-size:0.8rem; padding:2px 6px; background:var(--bg-secondary); border-radius:4px; }
.copyable:hover { background:var(--bg-hover); }
.p2p-add-remote { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
.p2p-add-remote h4 { font-size:0.9rem; margin-bottom:8px; }
.stats-grid { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:16px; }
.stat-card { background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px; padding:16px 24px; text-align:center; min-width:100px; }
.stat-value { font-size:1.5rem; font-weight:bold; }
.stat-label { font-size:0.8rem; color:var(--text-muted); margin-top:4px; }
.admin-table-wrap { overflow-x:auto; margin-bottom:16px; }
.role-select, .owner-select { font-size:0.8rem; padding:4px 8px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:4px; }
`
const scripts = `
(function () {
var basePath = ${JSON.stringify(pathPrefix)};
// Copy on click
document.querySelectorAll('.copyable').forEach(function (el) {
el.addEventListener('click', function () {
var text = el.getAttribute('data-copy') || el.textContent;
navigator.clipboard.writeText(text);
var orig = el.textContent;
el.textContent = 'Copied!';
setTimeout(function () { el.textContent = orig; }, 2000);
});
});
// P2P announce/unannounce
document.querySelectorAll('.p2p-action').forEach(function (btn) {
btn.addEventListener('click', async function () {
var action = btn.getAttribute('data-action');
var slug = btn.getAttribute('data-slug');
btn.disabled = true;
try {
await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(slug) + '/' + action, { method: 'POST' });
window.location.reload();
} catch (e) { alert('Failed: ' + e.message); btn.disabled = false; }
});
});
// Republish site
var btnRepub = document.getElementById('btn-republish');
if (btnRepub) btnRepub.addEventListener('click', async function () {
btnRepub.disabled = true;
try {
await window.fetch(basePath + '/api/site/publish', { method: 'POST' });
btnRepub.textContent = 'Published!';
setTimeout(function () { btnRepub.textContent = 'Republish'; btnRepub.disabled = false; }, 2000);
} catch (e) { alert('Failed: ' + e.message); btnRepub.disabled = false; }
});
// Add remote notebook
var btnAddRemote = document.getElementById('btn-add-remote');
if (btnAddRemote) btnAddRemote.addEventListener('click', async function () {
var key = document.getElementById('remote-key').value.trim();
var slug = document.getElementById('remote-slug').value.trim();
if (!key || key.length !== 64) { alert('Enter a valid 64-char hex drive key'); return; }
btnAddRemote.disabled = true;
try {
await window.fetch(basePath + '/api/sync/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ driveKey: key, slug: slug || undefined })
});
window.location.reload();
} catch (e) { alert('Failed: ' + e.message); btnAddRemote.disabled = false; }
});
// Role change
document.querySelectorAll('.role-select').forEach(function (sel) {
sel.addEventListener('change', async function () {
var userId = sel.getAttribute('data-userid');
var role = sel.value;
if (!role) return;
try {
if (role === 'read') {
await window.fetch(basePath + '/api/admin/demote', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: userId, role: 'read' })
});
} else {
await window.fetch(basePath + '/api/admin/promote', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: userId, role: role })
});
}
window.location.reload();
} catch (e) { alert('Failed: ' + e.message); sel.value = ''; }
});
});
// Delete user
document.querySelectorAll('.delete-user').forEach(function (btn) {
btn.addEventListener('click', async function () {
var userId = btn.getAttribute('data-userid');
var name = btn.getAttribute('data-name');
if (!confirm('Delete user ' + name + '? This cannot be undone.')) return;
try {
await window.fetch(basePath + '/api/admin/delete-user', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: userId })
});
window.location.reload();
} catch (e) { alert('Failed: ' + e.message); }
});
});
// Transfer notebook
document.querySelectorAll('.owner-select').forEach(function (sel) {
sel.addEventListener('change', async function () {
var slug = sel.getAttribute('data-slug');
var newOwner = sel.value;
if (!newOwner) return;
try {
await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(slug), {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ownerId: newOwner })
});
window.location.reload();
} catch (e) { alert('Failed: ' + e.message); sel.value = ''; }
});
});
// Multi-writer link
var btnLink = document.getElementById('btn-mw-link');
if (btnLink) btnLink.addEventListener('click', async function () {
btnLink.disabled = true;
try {
var r = await window.fetch(basePath + '/api/multiwriter/link', { method: 'POST' });
var data = await r.json();
var inviteEl = document.getElementById('mw-invite');
var codeEl = document.getElementById('mw-invite-code');
if (inviteEl && codeEl && data.invite) {
codeEl.value = data.invite;
inviteEl.style.display = 'block';
}
} catch (e) { alert('Failed: ' + e.message); }
btnLink.disabled = false;
});
// Multi-writer join
var btnJoin = document.getElementById('btn-mw-join');
if (btnJoin) btnJoin.addEventListener('click', async function () {
var invite = prompt('Paste the invite code:');
if (!invite) return;
try {
await window.fetch(basePath + '/api/multiwriter/join', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite: invite.trim() })
});
window.location.reload();
} catch (e) { alert('Failed: ' + e.message); }
});
// Copy invite
var btnCopy = document.getElementById('btn-mw-copy');
if (btnCopy) btnCopy.addEventListener('click', function () {
var code = document.getElementById('mw-invite-code');
if (code) navigator.clipboard.writeText(code.value);
});
})();
`
return renderPage('Admin', content, { pathPrefix, activePage: 'admin', css, scripts, maxWidth: 1100 })
}