OverviewFilesCommitsIssuesDocsReleasesPeers

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

195 lines · javascript ref:
/**
 * Note View — Single note display with rendered markdown
 *
 * SSR view showing note content, tags, and links panel.
 */

import { renderPage } from '../page-layout.js'
import { renderMarkdown } from '../markdown.js'

function esc (s) {
  return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

/**
 * Render the note view page
 *
 * @param {Object} notebook - Notebook metadata
 * @param {Object} note - Note object with content, tags, etc.
 * @param {Object} opts - { pathPrefix, links, backlinks, canWrite }
 * @returns {string} Full HTML page
 */
export function renderNotePage (notebook, note, opts = {}) {
  const pathPrefix = opts.pathPrefix || ''
  const canWrite = opts.canWrite || false
  const links = opts.links || []
  const backlinks = opts.backlinks || []
  const nbSlug = notebook.slug

  let content = ''

  // Navigation and actions
  content += `<div class="note-header">`
  content += `<a class="back-link" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}">&larr; ${esc(notebook.name || nbSlug)}</a>`
  if (canWrite) {
    content += `<div class="note-actions">`
    content += `<a class="btn" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}/notes/${encodeURIComponent(note.slug || note.id)}/edit">Edit</a>`
    content += `</div>`
  }
  content += `</div>`

  // Title
  content += `<h1>${esc(note.title || 'Untitled')}</h1>`

  // Meta
  content += '<div class="meta">'
  if (note.updatedAt) {
    content += `Updated ${new Date(note.updatedAt).toLocaleDateString()}`
  } else if (note.createdAt) {
    content += `Created ${new Date(note.createdAt).toLocaleDateString()}`
  }
  content += '</div>'

  // Tags
  if (note.tags && note.tags.length > 0) {
    content += '<div class="tags">'
    for (const t of note.tags) {
      content += `<span class="tag">${esc(t)}</span>`
    }
    content += '</div>'
  }

  // Content
  if (note.type === 'spreadsheet' && note.content) {
    content += renderSpreadsheet(note.content)
  } else if (note.type === 'calendar' && note.content) {
    content += renderCalendarEvents(note.content)
  } else {
    const renderedMd = renderMarkdown(note.content || '', {
      notebookSlug: nbSlug,
      pathPrefix
    })
    content += `<div class="markdown-body">${renderedMd}</div>`
  }

  // Links panel
  if (links.length > 0 || backlinks.length > 0) {
    content += '<div class="links-panel">'
    if (links.length > 0) {
      content += '<div class="links-section">'
      content += '<h3>Links</h3>'
      for (const link of links) {
        const href = `${pathPrefix}/notebooks/${encodeURIComponent(link.notebook || nbSlug)}/notes/${encodeURIComponent(link.slug || link.id || link.noteId)}`
        content += `<a class="link-item" href="${href}">${esc(link.title || link.target || 'Untitled')}</a>`
      }
      content += '</div>'
    }
    if (backlinks.length > 0) {
      content += '<div class="links-section">'
      content += '<h3>Backlinks</h3>'
      for (const bl of backlinks) {
        const href = `${pathPrefix}/notebooks/${encodeURIComponent(bl.notebook || nbSlug)}/notes/${encodeURIComponent(bl.slug || bl.id || bl.noteId)}`
        content += `<a class="link-item" href="${href}">${esc(bl.title || 'Untitled')}</a>`
      }
      content += '</div>'
    }
    content += '</div>'
  }

  const css = `
    .note-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
    .note-actions { display: flex; gap: 8px; }
    .links-panel { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--border); }
    .links-section { margin-bottom: 16px; }
    .links-section h3 { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; }
    .link-item {
      display: inline-block; padding: 4px 12px; margin: 2px 4px 2px 0;
      background: var(--bg-secondary); border-radius: 6px; font-size: 0.85rem;
      text-decoration: none; color: var(--accent);
    }
    .link-item:hover { background: var(--bg-hover); text-decoration: none; }
  `

  // Include video player script for HLS upgrade
  const scripts = `
    (function () {
      var players = document.querySelectorAll('.hn-video-player');
      if (players.length === 0) return;

      var script = document.createElement('script');
      script.src = '${pathPrefix}/web/vendor/hls.min.js';
      script.onload = function () {
        players.forEach(function (el) {
          var stream = el.getAttribute('data-stream');
          var fallback = el.getAttribute('data-fallback');
          var video = el.querySelector('video');
          if (!stream || !video) return;

          if (typeof Hls !== 'undefined' && Hls.isSupported()) {
            var hls = new Hls();
            hls.loadSource(stream);
            hls.attachMedia(video);
          } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
            video.src = stream;
          }
        });
      };
      document.body.appendChild(script);
    })();
  `

  return renderPage(note.title || 'Note', content, { pathPrefix, activePage: '', css, scripts })
}

/**
 * Render spreadsheet data as an HTML table
 */
function renderSpreadsheet (content) {
  try {
    const data = typeof content === 'string' ? JSON.parse(content) : content
    if (!Array.isArray(data)) return '<p class="meta">Invalid spreadsheet data</p>'

    let html = '<table class="spreadsheet">'
    for (let r = 0; r < data.length; r++) {
      html += '<tr>'
      const row = data[r] || []
      for (let c = 0; c < row.length; c++) {
        const tag = r === 0 ? 'th' : 'td'
        html += `<${tag}>${esc(String(row[c] || ''))}</${tag}>`
      }
      html += '</tr>'
    }
    html += '</table>'
    return html
  } catch {
    return '<p class="meta">Could not render spreadsheet</p>'
  }
}

/**
 * Render calendar events as a simple list (no interactive calendar in SSR)
 */
function renderCalendarEvents (content) {
  try {
    const events = typeof content === 'string' ? JSON.parse(content) : content
    if (!Array.isArray(events)) return '<p class="meta">Invalid calendar data</p>'

    let html = '<div class="calendar-events">'
    html += '<h3>Events</h3>'
    if (events.length === 0) {
      html += '<p class="meta">No events</p>'
    } else {
      for (const ev of events) {
        html += '<div class="calendar-event">'
        html += `<strong>${esc(ev.title || 'Untitled')}</strong>`
        if (ev.start) html += ` <span class="meta">${new Date(ev.start).toLocaleDateString()}</span>`
        html += '</div>'
      }
    }
    html += '</div>'
    return html
  } catch {
    return '<p class="meta">Could not render calendar</p>'
  }
}