/**
* Editor Page — SSR shell for note editing
*
* Loads the appropriate editor (TOUI markdown, jSpreadsheet, FullCalendar)
* via client-side JavaScript. The server renders the page shell with
* toolbar, title/tags inputs, and the editor container.
*/
import { renderPage } from '../page-layout.js'
function esc (s) {
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
/**
* Render the editor page
*
* @param {Object} notebook - Notebook metadata
* @param {Object|null} note - Existing note (null for new note)
* @param {Object} opts - { pathPrefix, noteType }
* @returns {string} Full HTML page
*/
export function renderEditorPage (notebook, note, opts = {}) {
const pathPrefix = opts.pathPrefix || ''
const nbSlug = notebook.slug
const isNew = !note
const noteType = (note && note.type) || opts.noteType || 'markdown'
const title = isNew ? '' : (note.title || '')
const tags = isNew ? '' : (note.tags || []).join(', ')
const noteId = note ? note.id : null
// Serialize note content for the editor to pick up
const contentJson = JSON.stringify(note ? (note.content || '') : '')
let content = ''
// Editor toolbar
content += `<div class="editor-toolbar">
<a class="back-link" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}">← ${esc(notebook.name || nbSlug)}</a>
<input type="text" id="editor-title" placeholder="Note title" value="${esc(title)}">
<input type="text" id="editor-tags" placeholder="Tags (comma-separated)" value="${esc(tags)}">
<div class="editor-actions">
<button class="btn btn-primary" id="btn-save">Save</button>
<a class="btn" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}${noteId ? '/notes/' + encodeURIComponent(note.slug || noteId) : ''}">Cancel</a>
</div>
</div>`
// Editor container — JS fills this
content += '<div id="editor-container"></div>'
// Upload progress overlay (hidden)
content += `<div id="upload-overlay" style="display:none">
<div class="upload-box">
<div id="upload-label">Uploading...</div>
<div class="upload-track"><div id="upload-bar" class="upload-bar"></div></div>
<div id="upload-pct">0%</div>
</div>
</div>`
const css = `
.editor-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:16px; }
.editor-toolbar input[type="text"] { flex:1; min-width:200px; }
.editor-actions { display:flex; gap:8px; margin-left:auto; }
#editor-container { min-height:60vh; }
#upload-overlay {
position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:10000;
display:flex; align-items:center; justify-content:center;
}
.upload-box { background:var(--bg-secondary); padding:24px; border-radius:8px; min-width:300px; text-align:center; }
.upload-track { height:8px; background:var(--bg-card); border-radius:4px; margin:12px 0; overflow:hidden; }
.upload-bar { height:100%; background:var(--accent); border-radius:4px; width:0%; transition:width 0.2s; }
/* TOUI editor theme overrides */
.toastui-editor-defaultUI { background:var(--bg) !important; }
.toastui-editor-defaultUI .toastui-editor-md-container { background:var(--bg) !important; }
.ProseMirror { color:var(--text) !important; min-height:50vh; }
`
// Vendor scripts needed for the editor
const vendorHead = `
<link rel="stylesheet" href="${pathPrefix}/web/vendor/toastui-editor.min.css">
<link rel="stylesheet" href="${pathPrefix}/web/vendor/jsuites.min.css">
<link rel="stylesheet" href="${pathPrefix}/web/vendor/jspreadsheet.min.css">
<script src="${pathPrefix}/web/vendor/toastui-editor-all.min.js"></script>
<script src="${pathPrefix}/web/vendor/jsuites.min.js"></script>
<script src="${pathPrefix}/web/vendor/jspreadsheet.min.js"></script>
<script src="${pathPrefix}/web/vendor/fullcalendar.min.js"></script>
`
const scripts = `
(function () {
var basePath = ${JSON.stringify(pathPrefix)};
var nbSlug = ${JSON.stringify(nbSlug)};
var noteId = ${JSON.stringify(noteId)};
var noteType = ${JSON.stringify(noteType)};
var initialContent = ${contentJson};
var container = document.getElementById('editor-container');
var tuiEditor = null;
var spreadsheetInstance = null;
var calendarInstance = null;
// ========== UPLOAD HELPERS ==========
function showUploadProgress (label) {
document.getElementById('upload-label').textContent = label || 'Uploading...';
document.getElementById('upload-bar').style.width = '0%';
document.getElementById('upload-pct').textContent = '0%';
document.getElementById('upload-overlay').style.display = 'flex';
}
function updateUploadProgress (pct) {
document.getElementById('upload-bar').style.width = pct + '%';
document.getElementById('upload-pct').textContent = Math.round(pct) + '%';
}
function hideUploadProgress () {
document.getElementById('upload-overlay').style.display = 'none';
}
async function uploadMedia (file, onProgress) {
var headers = {};
if (window.HnIdentity && window.HnIdentity.getPublicKey()) {
try {
var ch = await window.HnIdentity.getFreshChallenge();
var sig = await window.HnIdentity.signChallenge(ch.challenge);
headers['X-Wirecore-PublicKey'] = window.HnIdentity.getPublicKey();
headers['X-Wirecore-Signature'] = sig;
headers['X-Wirecore-Challenge'] = ch.challengeId;
} catch (e) {}
}
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/media');
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
for (var h in headers) xhr.setRequestHeader(h, headers[h]);
xhr.upload.onprogress = function (e) { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total * 100); };
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(new Error('Invalid response')); }
} else { reject(new Error('Upload failed: ' + xhr.status)); }
};
xhr.onerror = function () { reject(new Error('Network error')); };
xhr.send(file);
});
}
function insertRawMarkdown (text) {
if (!tuiEditor) return;
tuiEditor.insertText(text);
}
// ========== MARKDOWN EDITOR ==========
if (noteType === 'markdown' || noteType === 'md' || !noteType) {
// Video upload button
var videoBtnEl = document.createElement('button');
videoBtnEl.className = 'toastui-editor-toolbar-icons';
videoBtnEl.style.cssText = 'background:none;font-size:14px;cursor:pointer;margin:0;padding:4px 8px;';
videoBtnEl.textContent = '🎬';
videoBtnEl.title = 'Insert video';
var videoInput = document.createElement('input');
videoInput.type = 'file'; videoInput.accept = 'video/*'; videoInput.style.display = 'none';
document.body.appendChild(videoInput);
videoBtnEl.addEventListener('click', function () { videoInput.click(); });
videoInput.addEventListener('change', async function () {
if (!videoInput.files[0]) return;
showUploadProgress('Uploading video...');
try {
var data = await uploadMedia(videoInput.files[0], updateUploadProgress);
hideUploadProgress();
insertRawMarkdown('');
} catch (e) { hideUploadProgress(); alert('Upload failed: ' + e.message); }
videoInput.value = '';
});
// Gallery upload button
var galleryBtnEl = document.createElement('button');
galleryBtnEl.className = 'toastui-editor-toolbar-icons';
galleryBtnEl.style.cssText = 'background:none;font-size:14px;cursor:pointer;margin:0;padding:4px 8px;';
galleryBtnEl.textContent = '🖼';
galleryBtnEl.title = 'Insert gallery';
var galleryInput = document.createElement('input');
galleryInput.type = 'file'; galleryInput.accept = 'image/*'; galleryInput.multiple = true; galleryInput.style.display = 'none';
document.body.appendChild(galleryInput);
galleryBtnEl.addEventListener('click', function () { galleryInput.click(); });
galleryInput.addEventListener('change', async function () {
var files = Array.from(galleryInput.files);
if (files.length === 0) return;
showUploadProgress('Uploading gallery (' + files.length + ' images)...');
var lines = [];
for (var i = 0; i < files.length; i++) {
updateUploadProgress((i / files.length) * 100);
try {
var data = await uploadMedia(files[i], function () {});
lines.push('![' + (files[i].name || 'image') + '](' + basePath + data.url + ')');
} catch (e) { console.error('Gallery upload failed:', e); }
}
hideUploadProgress();
if (lines.length) insertRawMarkdown('\\n\`\`\`gallery\\n' + lines.join('\\n') + '\\n\`\`\`\\n');
galleryInput.value = '';
});
tuiEditor = new toastui.Editor({
el: container,
initialEditType: 'markdown',
previewStyle: 'vertical',
height: 'calc(100vh - 200px)',
initialValue: initialContent,
usageStatistics: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'],
['code', 'codeblock'],
[{ name: 'video', tooltip: 'Insert video', el: videoBtnEl },
{ name: 'gallery', tooltip: 'Insert gallery', el: galleryBtnEl }],
['scrollSync']
],
hooks: {
addImageBlobHook: function (blob, callback) {
var ct = blob.type || 'application/octet-stream';
var label = ct.startsWith('video/') ? 'Uploading video...' : 'Uploading image...';
showUploadProgress(label);
uploadMedia(blob, updateUploadProgress)
.then(function (data) {
hideUploadProgress();
var mediaUrl = basePath + data.url;
if (ct.startsWith('video/')) { insertRawMarkdown(''); callback('', ''); }
else { callback(mediaUrl, blob.name || 'image'); }
})
.catch(function (err) { hideUploadProgress(); alert('Upload failed: ' + err.message); callback('', ''); });
return false;
}
}
});
}
// ========== SPREADSHEET EDITOR ==========
else if (noteType === 'spreadsheet') {
var sheetData = [['', '', '', ''], ['', '', '', ''], ['', '', '', '']];
var sheetColumns;
if (initialContent) {
try {
var parsed = JSON.parse(initialContent);
sheetData = parsed.data || sheetData;
sheetColumns = parsed.columns;
} catch (e) {}
}
spreadsheetInstance = jspreadsheet(container, {
data: sheetData,
columns: sheetColumns,
minDimensions: [4, 5],
tableOverflow: true,
tableWidth: '100%'
});
}
// ========== CALENDAR EDITOR ==========
else if (noteType === 'calendar') {
var calData = { events: [], settings: {} };
if (initialContent) { try { calData = JSON.parse(initialContent); } catch (e) {} }
var calSettings = calData.settings || {};
calendarInstance = new FullCalendar.Calendar(container, {
initialView: calSettings.defaultView || 'dayGridMonth',
firstDay: calSettings.firstDay != null ? calSettings.firstDay : 1,
headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
events: (calData.events || []).map(function (ev) {
return { id: ev.id, title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay, color: ev.color, extendedProps: { description: ev.description } };
}),
editable: true,
selectable: true,
height: 'auto',
dateClick: function (info) { showEventModal({ start: info.dateStr, allDay: info.allDay }); },
eventClick: function (info) {
var ev = info.event;
showEventModal({ id: ev.id, title: ev.title, start: ev.startStr, end: ev.endStr, allDay: ev.allDay, color: ev.backgroundColor, description: (ev.extendedProps && ev.extendedProps.description) || '' });
}
});
calendarInstance.render();
function showEventModal (evt) {
var isEdit = !!evt.id;
var modal = document.createElement('div');
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center';
modal.innerHTML = '<div style="background:var(--bg-secondary);padding:24px;border-radius:8px;min-width:350px;max-width:500px">'
+ '<h3>' + (isEdit ? 'Edit Event' : 'New Event') + '</h3>'
+ '<div style="margin:12px 0"><label>Title</label><input type="text" id="evt-title" value="' + esc(evt.title || '') + '" style="width:100%"></div>'
+ '<div style="margin:12px 0"><label>Start</label><input type="text" id="evt-start" value="' + esc(evt.start || '') + '" style="width:100%"></div>'
+ '<div style="margin:12px 0"><label>End</label><input type="text" id="evt-end" value="' + esc(evt.end || '') + '" style="width:100%"></div>'
+ '<div style="margin:12px 0"><label>Color</label><input type="color" id="evt-color" value="' + (evt.color || '#569cd6') + '"></div>'
+ '<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">'
+ (isEdit ? '<button id="evt-delete" class="btn btn-danger" style="margin-right:auto">Delete</button>' : '')
+ '<button id="evt-cancel" class="btn">Cancel</button>'
+ '<button id="evt-save" class="btn btn-primary">Save</button></div></div>';
document.body.appendChild(modal);
modal.querySelector('#evt-cancel').addEventListener('click', function () { modal.remove(); });
if (isEdit) modal.querySelector('#evt-delete').addEventListener('click', function () {
var existing = calendarInstance.getEventById(evt.id);
if (existing) existing.remove();
modal.remove();
});
modal.querySelector('#evt-save').addEventListener('click', function () {
var newEvt = {
id: evt.id || 'evt_' + Date.now(),
title: modal.querySelector('#evt-title').value || 'Untitled',
start: modal.querySelector('#evt-start').value,
end: modal.querySelector('#evt-end').value || null,
color: modal.querySelector('#evt-color').value
};
if (isEdit) { var existing = calendarInstance.getEventById(evt.id); if (existing) existing.remove(); }
calendarInstance.addEvent(newEvt);
modal.remove();
});
}
}
// ========== SAVE ==========
document.getElementById('btn-save').addEventListener('click', async function () {
var titleInput = document.getElementById('editor-title');
var tagsInput = document.getElementById('editor-tags');
var noteTitle = titleInput.value.trim();
var noteTags = tagsInput.value.split(',').map(function (t) { return t.trim(); }).filter(function (t) { return t; });
if (!noteTitle) { alert('Title is required'); titleInput.focus(); return; }
var content = '';
var type = noteType;
if (spreadsheetInstance) {
type = 'spreadsheet';
var sheetData = spreadsheetInstance.getData ? spreadsheetInstance.getData() : [];
var sheetHeaders = spreadsheetInstance.getHeaders ? spreadsheetInstance.getHeaders(true) : [];
content = JSON.stringify({ data: sheetData, columns: sheetHeaders.length ? sheetHeaders.map(function (h) { return { title: h }; }) : undefined });
} else if (calendarInstance) {
type = 'calendar';
var events = calendarInstance.getEvents().map(function (ev) {
return { id: ev.id, title: ev.title, start: ev.startStr, end: ev.endStr || null, allDay: ev.allDay, color: ev.backgroundColor || null, description: (ev.extendedProps && ev.extendedProps.description) || '' };
});
content = JSON.stringify({ events: events, settings: { defaultView: calendarInstance.view.type, firstDay: 1 } });
} else if (tuiEditor) {
content = tuiEditor.getMarkdown();
}
var btn = document.getElementById('btn-save');
btn.disabled = true; btn.textContent = 'Saving...';
try {
if (noteId) {
await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(noteId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: noteTitle, content: content, tags: noteTags })
});
window.location.href = basePath + '/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(noteId);
} else {
var res = await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: noteTitle, content: content, tags: noteTags, type: type })
});
var data = await res.json();
var note = data.note || data;
window.location.href = basePath + '/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(note.slug || note.id);
}
} catch (err) {
alert('Error saving: ' + (err.message || 'Unknown error'));
btn.disabled = false; btn.textContent = 'Save';
}
});
})();
`
const pageTitle = isNew ? 'New Note' : 'Edit: ' + title
return renderPage(pageTitle, content, { pathPrefix, activePage: '', css, scripts, head: vendorHead, maxWidth: 1200 })
}