OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / views / admin-ui.js

337 lines · javascript ref:
/**
 * 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

/**
 * 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">&mdash;</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 })
}