OverviewFilesCommitsIssuesDocsReleasesPeers

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

264 lines · javascript ref:
/**
 * Profile Page — User settings, identity management, theme
 *
 * 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 profile/settings page
 *
 * @param {Object} profile - User profile from /api/user/profile
 * @param {Object} whoami - Auth info from /api/whoami
 * @param {Object} opts - { pathPrefix }
 * @returns {string} Full HTML page
 */
export function renderProfilePage (profile, whoami, opts = {}) {
  const pathPrefix = opts.pathPrefix || ''
  const username = profile.username || ''
  const displayName = profile.name || 'Anonymous'
  const pubkey = profile.publicKey || ''
  const method = profile.method || whoami.method || 'web-identity'
  const isAdmin = whoami.permissions && whoami.permissions.admin
  const role = isAdmin ? 'admin' : 'writer'
  const currentTheme = profile.theme || 'dark'
  const identities = profile.identities || []

  let content = '<div class="settings-card">'

  // User status card
  content += `<div class="profile-header">
    <div class="profile-info">
      <div class="profile-name">${esc(displayName)}</div>
      ${username ? `<div class="meta">@${esc(username)}</div>` : '<div class="meta">No username set</div>'}
    </div>
    <span class="badge badge-${role === 'admin' ? 'success' : 'secondary'}">${esc(role)}</span>
  </div>`

  // Username section
  content += '<div class="settings-section">'
  content += '<div class="settings-row"><label>Username</label><div class="settings-value">'
  if (username) {
    content += `<span>@${esc(username)}</span> <button class="btn btn-sm" id="btn-change-username">Change</button>`
  } else {
    content += `<span class="meta">Not set</span> <button class="btn btn-sm btn-primary" id="btn-set-username">Set username</button>`
  }
  content += '</div></div>'

  // Display name
  content += `<div class="settings-row"><label>Display Name</label><div class="settings-value">
    <span id="profile-display-name">${esc(displayName)}</span>
    <button class="btn btn-sm" id="btn-edit-name">Edit</button>
  </div></div>`

  // Theme picker
  content += '<div class="settings-row"><label>Theme</label><div class="settings-value"><div class="theme-options">'
  for (const theme of ['light', 'dark', 'dark-blue']) {
    const active = currentTheme === theme ? ' theme-active' : ''
    const label = theme === 'dark-blue' ? 'Dark Blue' : theme.charAt(0).toUpperCase() + theme.slice(1)
    content += `<button class="theme-option${active}" data-theme="${theme}">
      <div class="theme-swatch swatch-${theme}"></div>${label}
    </button>`
  }
  content += '</div></div></div>'

  // Auth method
  content += `<div class="settings-row"><label>Auth Method</label><div class="settings-value">
    <span>${esc(method)}</span>
  </div></div>`
  content += '</div>'

  // Identity section
  content += '<div class="settings-section"><h3>Identity</h3>'
  if (identities.length > 0) {
    for (const ident of identities) {
      content += `<div class="settings-row"><label>${esc(ident.method || 'identity')}</label>
        <div class="settings-value"><code class="identity-code">${esc(ident.identityId || ident.publicKey || '')}</code></div>
      </div>`
    }
  }

  // Keypair management buttons
  content += `<div class="keypair-actions">
    <button class="btn btn-sm" id="btn-copy-pubkey">Copy Public Key</button>
    <button class="btn btn-sm" id="btn-copy-keypair">Copy Keypair</button>
    <button class="btn btn-sm" id="btn-show-keypair">Show Keypair</button>
    <button class="btn btn-sm" id="btn-export-keypair">Export JSON</button>
  </div>`
  content += '<div id="keypair-display" style="display:none"></div>'
  content += '</div>'

  // Device linking
  content += `<div class="settings-section"><h3>Link Another Device</h3>
    <p class="meta" style="margin-bottom:8px">Import your keypair on another device to access your account there.</p>
    <button class="btn btn-sm" id="btn-import-keypair">Import Keypair</button>
    <div id="import-form" style="display:none;margin-top:12px">
      <input type="text" id="import-input" placeholder="Paste 132-char keypair string" style="margin-bottom:8px">
      <button class="btn btn-sm btn-primary" id="btn-do-import">Import</button>
    </div>
  </div>`

  // Danger zone
  content += `<div class="settings-section"><h3 style="color:var(--danger)">Danger Zone</h3>
    <button class="btn btn-sm btn-danger" id="btn-logout">Clear Identity & Logout</button>
  </div>`

  content += '</div>'

  const css = `
    .settings-card { max-width: 600px; }
    .profile-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; padding-bottom:16px; border-bottom:1px solid var(--border); }
    .profile-info { flex:1; }
    .profile-name { font-size:1.3rem; font-weight:bold; }
    .settings-section { margin-bottom:24px; padding-bottom:16px; border-bottom:1px solid var(--border); }
    .settings-section h3 { font-size:1rem; margin-bottom:12px; }
    .settings-row { display:flex; align-items:center; padding:8px 0; gap:12px; }
    .settings-row label { min-width:120px; font-size:0.85rem; color:var(--text-muted); }
    .settings-value { flex:1; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
    .btn-sm { padding:4px 12px; font-size:0.8rem; }
    .identity-code { font-size:0.7rem; word-break:break-all; color:var(--text-muted); }
    .keypair-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
    .theme-options { display:flex; gap:8px; }
    .theme-option { display:flex; align-items:center; gap:6px; padding:6px 12px; border:1px solid var(--border); border-radius:6px; background:var(--bg-secondary); color:var(--text); cursor:pointer; font-size:0.85rem; }
    .theme-option:hover { border-color:var(--accent); }
    .theme-active { border-color:var(--accent); background:var(--bg-hover); }
    .theme-swatch { width:16px; height:16px; border-radius:50%; border:1px solid var(--border); }
    .swatch-light { background:#fff; }
    .swatch-dark { background:#0d1117; }
    .swatch-dark-blue { background:#1a2332; }
  `

  const scripts = `
  (function () {
    var basePath = ${JSON.stringify(pathPrefix)};

    // Theme picker
    document.querySelectorAll('.theme-option').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var theme = btn.getAttribute('data-theme');
        document.documentElement.setAttribute('data-theme', theme);
        localStorage.setItem('hn-theme', theme);
        document.querySelectorAll('.theme-option').forEach(function (b) { b.classList.remove('theme-active'); });
        btn.classList.add('theme-active');
        window.fetch(basePath + '/api/user/profile', {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ theme: theme })
        }).catch(function () {});
      });
    });

    // Copy public key
    var btnPub = document.getElementById('btn-copy-pubkey');
    if (btnPub) btnPub.addEventListener('click', function () {
      var pk = window.HnIdentity && window.HnIdentity.getPublicKey();
      if (pk) { navigator.clipboard.writeText(pk); btnPub.textContent = 'Copied!'; setTimeout(function () { btnPub.textContent = 'Copy Public Key'; }, 2000); }
    });

    // Copy keypair
    var btnKp = document.getElementById('btn-copy-keypair');
    if (btnKp) btnKp.addEventListener('click', function () {
      var kp = window.HnIdentity && window.HnIdentity.getKeypair();
      if (kp) { navigator.clipboard.writeText(kp); btnKp.textContent = 'Copied!'; setTimeout(function () { btnKp.textContent = 'Copy Keypair'; }, 2000); }
    });

    // Show keypair
    var btnShow = document.getElementById('btn-show-keypair');
    var kpDisplay = document.getElementById('keypair-display');
    if (btnShow && kpDisplay) btnShow.addEventListener('click', function () {
      if (kpDisplay.style.display === 'none') {
        var kp = window.HnIdentity && window.HnIdentity.getKeypair();
        var pk = window.HnIdentity && window.HnIdentity.getPublicKey();
        kpDisplay.innerHTML = '<div style="background:var(--bg-secondary);padding:12px;border-radius:6px;font-family:monospace;font-size:0.7rem;word-break:break-all">'
          + '<div><strong>Public Key:</strong></div><div style="margin:4px 0">' + (pk || 'none') + '</div>'
          + '<div style="margin-top:8px"><strong style="color:var(--danger)">Full Keypair (KEEP SECRET):</strong></div>'
          + '<div style="margin:4px 0">' + (kp || 'none') + '</div></div>';
        kpDisplay.style.display = 'block';
        btnShow.textContent = 'Hide Keypair';
      } else {
        kpDisplay.style.display = 'none';
        btnShow.textContent = 'Show Keypair';
      }
    });

    // Export keypair
    var btnExport = document.getElementById('btn-export-keypair');
    if (btnExport) btnExport.addEventListener('click', function () {
      var data = window.HnIdentity && window.HnIdentity.exportKeypair();
      if (!data) return;
      var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
      var a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = 'hypernote-identity.json';
      a.click();
    });

    // Import keypair
    var btnImport = document.getElementById('btn-import-keypair');
    var importForm = document.getElementById('import-form');
    if (btnImport && importForm) btnImport.addEventListener('click', function () {
      importForm.style.display = importForm.style.display === 'none' ? 'block' : 'none';
    });
    var btnDoImport = document.getElementById('btn-do-import');
    if (btnDoImport) btnDoImport.addEventListener('click', function () {
      var val = document.getElementById('import-input').value.trim();
      if (val.length !== 132) { alert('Keypair must be 132 characters'); return; }
      try {
        window.HnIdentity.importKeypair(val);
        window.location.reload();
      } catch (e) { alert('Invalid keypair: ' + e.message); }
    });

    // Username set/change
    function usernameAction () {
      var current = ${JSON.stringify(username)};
      var newName = prompt(current ? 'Change username (current: @' + current + '):' : 'Set your username:', current || '');
      if (newName === null) return;
      newName = newName.trim().toLowerCase();
      if (!newName) return;
      window.fetch(basePath + '/api/user/username', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: newName })
      }).then(function (r) {
        if (r.ok) window.location.reload();
        else r.json().then(function (d) { alert(d.error || 'Failed'); });
      }).catch(function (e) { alert('Error: ' + e.message); });
    }
    var btnSetUser = document.getElementById('btn-set-username');
    var btnChangeUser = document.getElementById('btn-change-username');
    if (btnSetUser) btnSetUser.addEventListener('click', usernameAction);
    if (btnChangeUser) btnChangeUser.addEventListener('click', usernameAction);

    // Edit name
    var btnEditName = document.getElementById('btn-edit-name');
    if (btnEditName) btnEditName.addEventListener('click', function () {
      var newName = prompt('Display name:', ${JSON.stringify(displayName)});
      if (newName === null) return;
      window.fetch(basePath + '/api/user/profile', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: newName.trim() })
      }).then(function (r) { if (r.ok) window.location.reload(); })
        .catch(function () {});
    });

    // Logout
    var btnLogout = document.getElementById('btn-logout');
    if (btnLogout) btnLogout.addEventListener('click', function () {
      if (confirm('Clear your identity? You will lose access unless you have exported your keypair.')) {
        window.HnIdentity.clear();
        window.location.href = basePath + '/';
      }
    });
  })();
  `

  return renderPage('Profile', content, { pathPrefix, activePage: 'profile', css, scripts })
}