/**
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
/**
* 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)}">← ${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>'
}
}