OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / page-layout.js

165 lines · javascript ref:
/**
 * Page Layout — shared HTML wrapper for all server-rendered pages
 *
 * Provides DOCTYPE, head, body, nav, identity script, and shared CSS.
 * Each page passes title, content, and optional page-specific CSS.
 *
 * Usage:
 *   import { renderPage } from './page-layout.js'
 *   return renderPage('Deploy', deployContent, { pathPrefix, activePage: 'deploy', css: pageSpecificCss })
 */

import { renderIdentityScript } from './identity-ui.js'
import { renderNav } from './nav-ui.js'

/**
 * Render a full HTML page
 *
 * @param {string} title — page title (appended with " - Pear Git")
 * @param {string} content — page body HTML
 * @param {Object} opts
 * @param {string} opts.pathPrefix — URL prefix (e.g., '/git')
 * @param {string} opts.activePage — active nav tab (e.g., 'repos', 'admin', 'deploy')
 * @param {string} opts.css — page-specific CSS (inserted after shared styles)
 * @param {string} opts.scripts — page-specific inline JS (inserted before </body>)
 * @param {number} opts.maxWidth — container max-width in px (default: 1200)
 * @returns {string} full HTML document
 */
export function renderPage (title, content, opts = {}) {
  const pathPrefix = opts.pathPrefix || ''
  const activePage = opts.activePage || ''
  const pageCss = opts.css || ''
  const pageScripts = opts.scripts || ''
  const headExtra = opts.head || ''
  const maxWidth = opts.maxWidth || 1200

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${escapeForAttr(title)} - Pear Git</title>
  ${headExtra}
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      padding: 20px;
    }
    a { color: #58a6ff; text-decoration: none; }
    a:hover { text-decoration: underline; }
    .container { max-width: ${maxWidth}px; margin: 0 auto; }

    /* Shared component styles */
    .badge {
      display: inline-block;
      padding: 4px 10px;
      border-radius: 12px;
      font-size: 12px;
      font-weight: 600;
    }

    button {
      background: #238636;
      color: white;
      border: none;
      padding: 8px 16px;
      border-radius: 6px;
      cursor: pointer;
      font-size: 14px;
      font-weight: 500;
    }
    button:hover { background: #2ea043; }
    button:disabled { opacity: 0.5; cursor: not-allowed; }
    button.secondary {
      background: #21262d;
      border: 1px solid #30363d;
    }
    button.secondary:hover { background: #30363d; }
    button.small { padding: 4px 10px; font-size: 12px; }
    button.danger { background: #da3633; }
    button.danger:hover { background: #f85149; }

    .form-group { margin-bottom: 15px; }
    .form-group label {
      display: block;
      margin-bottom: 6px;
      font-weight: 500;
      font-size: 14px;
    }
    .form-group input, .form-group select, .form-group textarea {
      width: 100%;
      max-width: 400px;
      background: #0d1117;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 8px 12px;
      color: #c9d1d9;
      font-size: 14px;
    }
    .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #58a6ff; }
    .help-text { font-size: 12px; color: #8b949e; margin-top: 4px; }

    .status-msg {
      padding: 12px 16px;
      border-radius: 6px;
      margin-bottom: 15px;
      font-size: 14px;
    }
    .status-msg.success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
    .status-msg.error { background: #2d1117; border: 1px solid #da3633; color: #f85149; }
    .status-msg.info { background: #0d1d32; border: 1px solid #1f6feb; color: #58a6ff; }

    .spinner {
      display: inline-block;
      width: 14px; height: 14px;
      border: 2px solid #30363d;
      border-top: 2px solid #58a6ff;
      border-radius: 50%;
      animation: spin 1s linear infinite;
      margin-right: 8px;
    }
    @keyframes spin { to { transform: rotate(360deg); } }

    .empty-state { text-align: center; padding: 40px 20px; color: #8b949e; }

    .card {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 20px;
      margin-bottom: 15px;
    }

    .data-table { width: 100%; border-collapse: collapse; }
    .data-table th {
      text-align: left; padding: 10px 15px;
      background: #0d1117; border-bottom: 1px solid #30363d;
      font-size: 12px; text-transform: uppercase; color: #8b949e;
    }
    .data-table td {
      padding: 10px 15px; border-bottom: 1px solid #21262d; font-size: 13px;
    }
    .data-table tr:hover td { background: #0d1117; }

    ${pageCss}
  </style>
</head>
<body>
  ${renderIdentityScript(pathPrefix)}
  <div class="container">
    ${renderNav(pathPrefix, activePage)}
    ${content}
  </div>
  ${pageScripts ? `<script>\n${pageScripts}\n</script>` : ''}
</body>
</html>`
}

function escapeForAttr (s) {
  if (!s) return ''
  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}