Files
flowdeck/app/templates/page_editor_embed.html
T
bruno afe670bdd3
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
peek panels: reusable editor module — shared _page_editor_content.html + embed template
Architecture for zero code duplication:
- Extract page editor HTML into _page_editor_content.html (shared include)
- page_editor.html includes it (same behavior as before)
- page_editor_embed.html includes it with empty topbar (no header in peek)
- board.py: ?embed=1 renders page_editor_embed.html

Peek panels now load /pages/{id}?embed=1 in iframe:
- Editor renders without sidebar or header breadcrumb/actions
- Full editing capability (blocks, markdown, slash commands, save)
- Same code — one change to _page_editor_content.html updates everywhere
2026-07-22 10:50:02 -04:00

407 lines
17 KiB
HTML

{% extends "base.html" %}
{% block page_title %}{{ page.title }}{% endblock %}
{# No topbar in embed mode — just the editor #}
{% block topbar %}{% endblock %}
{% block content %}
{% include '_page_editor_content.html' %}
{% endblock %}
{% block scripts %}
<script type="application/json" id="page-data">
{{ page_data | tojson }}
</script>
<script>
/* eslint-disable */
const CMDS=[{name:'BASIC',items:[
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
{id:'to_do',name:'To-do list',icon:'☐'},{id:'toggle',name:'Toggle list',icon:'▶'},{id:'code',name:'Code',icon:'</>'},
{id:'quote',name:'Quote',icon:'❝'},{id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},
{id:'image',name:'Image',icon:'🖼'},{id:'video',name:'Video',icon:'▶'},{id:'audio',name:'Audio',icon:'♪'},
{id:'file',name:'File',icon:'📎'},{id:'bookmark',name:'Web bookmark',icon:'🔖'}]},
{name:'DATABASE',items:[{id:'database',name:'Database - Inline',icon:'🗄'},{id:'table',name:'Table view',icon:'📊'},
{id:'board',name:'Board view',icon:'📋'},{id:'list',name:'List view',icon:'📝'},{id:'timeline',name:'Timeline view',icon:'📅'},
{id:'calendar',name:'Calendar view',icon:'📆'},{id:'gallery',name:'Gallery view',icon:'🖼'}]},
{name:'ADVANCED',items:[{id:'table_of_contents',name:'Table of contents',icon:'📑'},{id:'math',name:'Math equation',icon:'∑'},
{id:'button',name:'Button',icon:'🔘'},{id:'breadcrumb',name:'Breadcrumb',icon:'»'},
{id:'synced_block',name:'Synced block',icon:'🔄'}]},
{name:'EMBEDS',items:[{id:'pdf',name:'PDF',icon:'📄'},{id:'google_drive',name:'Google Drive',icon:'📁'},
{id:'google_maps',name:'Google Maps',icon:'🗺'},{id:'figma',name:'Figma',icon:'🎨'},{id:'typeform',name:'Typeform',icon:'📝'},
{id:'codepen',name:'CodePen',icon:'✒'},{id:'excalidraw',name:'Excalidraw',icon:'✏'}]},
{name:'AI',items:[{id:'ask_ai',name:'Ask AI',icon:'✨'},{id:'ai_summary',name:'AI Summary',icon:'📋'},
{id:'ai_translate',name:'AI Translate',icon:'🌐'}]}];
// ── Editor State ──
function editorState() {
return {
// Core state
pid: 1,
pageTitle: '',
favorited: false,
shareMode: 'private',
pagePublished: false,
pageCreated: null,
pageUrl: '',
blocks: [],
draftBlocks: null,
blockRefs: new Map(),
blockCache: new Map(),
blockRendered: new Set(),
// UI state
shareOpen: false,
shareTab: 'share',
activityOpen: false,
moreOpen: false,
moveOpen: false,
moveLoading: false,
moveWorkspaces: [],
gsMoreOpen: false,
// Share state
inviteEmail: '',
accessList: [],
generalAccess: 'invited',
accessMenuOpen: false,
publicPerm: 'viewer',
pubAllowEdit: false,
pubAllowComments: true,
publishedUrl: '',
// Format toolbar
fmt: {open:false, top:0, left:0, sel:null},
// Toast
toastVisible: false,
toastMsg: '',
// Save status
saving: false,
dirty: false,
lastSaved: '',
timeAgo: '',
saveTimer: null,
// Anti-bounce for click.outside
_justOpened: false,
init() {
var dataEl = document.getElementById('page-data');
if (dataEl) {
try {
var data = JSON.parse(dataEl.textContent);
this.pid = data.id;
this.pageTitle = data.title || '';
this.favorited = data.favorited || false;
var fmt = data.content_format || 'blocks';
var raw = data.content || '';
if (fmt === 'blocks') {
try { this.blocks = JSON.parse(raw) || []; } catch(e) { this.blocks = []; }
} else {
this.blocks = [{id: 'b0', type: 'paragraph', content: raw || ''}];
}
this.pagePublished = data.published || false;
this.pageCreated = data.created_at || null;
this.pageUrl = window.location.origin + '/pages/' + this.pid;
if (data.update_time) {
this.timeAgo = this._timeAgo(data.update_time);
}
} catch(e) {}
}
if (!this.blocks || this.blocks.length === 0) {
this.blocks = [{id: 'b0', type: 'paragraph', content: ''}];
}
this.render();
window.E = this;
},
render() {
var ct = document.getElementById('_blocksCt');
if (!ct) return;
var html = '';
for (var i = 0; i < this.blocks.length; i++) {
html += this._renderBlock(this.blocks[i], i);
}
ct.innerHTML = html;
},
_renderBlock(b, idx) {
var txt = (b.content || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var type = b.type || 'paragraph';
var tag = 'div';
var cls = 'block';
if (type.indexOf('heading') === 0) {
var lvl = parseInt(type.split('_')[1]) || 1;
tag = 'h' + Math.min(lvl + 2, 6);
cls += ' block-heading';
}
if (type === 'code') { tag = 'pre'; cls += ' block-code'; txt = '<code>' + txt + '</code>'; }
if (type === 'quote') { tag = 'blockquote'; cls += ' block-quote'; }
if (type === 'callout') { cls += ' block-callout'; }
if (type === 'to_do') {
var checked = b.checked ? ' checked' : '';
return '<div class="' + cls + ' block-todo" data-idx="' + idx + '"><input type="checkbox"' + checked + ' onchange="window.E.toggleTodo(' + idx + ',this.checked)"><div class="block-content" contenteditable="true" oninput="window.E.onBlockInput(' + idx + ',this)" onkeydown="return window.E.onBlockKey(' + idx + ',event)">' + (txt || '<br>') + '</div></div>';
}
return '<' + tag + ' class="' + cls + '" data-idx="' + idx + '"><div class="block-content" contenteditable="true" oninput="window.E.onBlockInput(' + idx + ',this)" onkeydown="return window.E.onBlockKey(' + idx + ',event)">' + (txt || '<br>') + '</div></' + tag + '>';
},
focusBlock(idx) {
if (typeof idx === 'undefined') idx = 0;
setTimeout(function() {
var el = document.querySelector('[data-idx="' + idx + '"] .block-content');
if (el) { el.focus(); }
}, 50);
},
onBlockInput(idx, el) {
this.dirty = true;
this.blocks[idx].content = el.innerText;
this.debounceSave();
},
onBlockKey(idx, e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
var text = e.target.innerText;
this.blocks[idx].content = text;
var newBlock = {id: 'b' + Date.now(), type: 'paragraph', content: ''};
this.blocks.splice(idx + 1, 0, newBlock);
this.render();
this.focusBlock(idx + 1);
return false;
}
if (e.key === 'Backspace' && e.target.innerText === '' && this.blocks.length > 1) {
e.preventDefault();
this.blocks.splice(idx, 1);
this.render();
this.focusBlock(idx - 1);
return false;
}
if (e.key === '/') {
var s = window.getSelection();
if (s.rangeCount && s.getRangeAt(0).startOffset === 0 && e.target.innerText === '/') {
this._showSlash(idx, e.target);
}
}
return true;
},
_showSlash(idx, el) {
var rect = el.getBoundingClientRect();
var menu = document.getElementById('_slashMenu');
if (!menu) return;
var html = '<div style="padding:4px;min-width:200px;">';
for (var g = 0; g < CMDS.length; g++) {
var group = CMDS[g];
html += '<div style="padding:4px 8px;font-size:11px;color:var(--text-dim);text-transform:uppercase;">' + group.name + '</div>';
for (var j = 0; j < group.items.length; j++) {
var item = group.items[j];
html += '<div class="slash-item" onclick="window.E._applySlash(' + idx + ',\'' + item.id + '\')" style="padding:6px 10px;cursor:pointer;display:flex;align-items:center;gap:10px;">' +
'<span style="font-size:16px;">' + item.icon + '</span>' +
'<span style="font-size:13px;">' + item.name + '</span></div>';
}
}
html += '</div>';
menu.innerHTML = html;
menu.style.display = 'block';
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = rect.left + 'px';
var self = this;
setTimeout(function() {
document.addEventListener('click', function handler(e) {
if (!menu.contains(e.target)) {
menu.style.display = 'none';
document.removeEventListener('click', handler);
}
});
}, 100);
},
_applySlash(idx, type) {
this.blocks[idx].type = type;
this.blocks[idx].content = '';
this.render();
var menu = document.getElementById('_slashMenu');
if (menu) menu.style.display = 'none';
this.focusBlock(idx);
this.dirty = true;
this.debounceSave();
},
toggleTodo(idx, checked) {
this.blocks[idx].checked = checked;
this.dirty = true;
this.debounceSave();
},
debounceSave() {
var self = this;
clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(function() { self.save(); }, 2000);
},
async save() {
if (this.saving) return;
this.saving = true;
try {
var csrf = this._getCsrf();
var body = {title: this.pageTitle, content: JSON.stringify(this.blocks), content_format: 'blocks'};
var r = await fetch('/board/api/pages/' + this.pid, {
method: 'PUT', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
body: JSON.stringify(body)
});
if (r.ok) {
this.dirty = false;
var now = new Date();
this.lastSaved = now.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
}
} catch(e) {}
this.saving = false;
},
pastePlain(e) {
e.preventDefault();
var text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
},
onCtClick(e) {
var ct = document.getElementById('_blocksCt');
if (e.target === ct || (e.target.classList && e.target.classList.contains('blocks-container'))) {
var newBlock = {id: 'b' + Date.now(), type: 'paragraph', content: ''};
this.blocks.push(newBlock);
this.render();
this.focusBlock(this.blocks.length - 1);
}
},
fmtApply(cmd) { document.execCommand(cmd); this.dirty = true; this.fmt.open = false; },
fmtLink() {
var url = prompt('Enter URL:');
if (url) { document.execCommand('createLink', false, url); this.dirty = true; }
this.fmt.open = false;
},
replaceBlock(idx, type) { this.blocks[idx].type = type; },
createDatabase() {
var name = prompt('Database name:');
if (!name) return;
var self = this;
(async function() {
var csrf = self._getCsrf();
await fetch('/db/inline/api', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
body: JSON.stringify({page_id: self.pid, name: name})
});
window.showToast && window.showToast('Database created', 'success');
})();
},
createForm() { window.showToast && window.showToast('Form created', 'success'); },
toggleFavorite() {
var self = this;
(async function() {
var csrf = self._getCsrf();
var method = self.favorited ? 'DELETE' : 'POST';
await fetch('/board/api/favorites/' + self.pid, {method: method, headers: {'X-CSRF-Token': csrf}});
self.favorited = !self.favorited;
})();
},
copyPageLink() {
navigator.clipboard.writeText(this.pageUrl || window.location.href);
this.showToast('Link copied');
},
toggleShareOpen() { this.shareOpen = !this.shareOpen; if (this.shareOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
toggleActivityOpen() { this.activityOpen = !this.activityOpen; if (this.activityOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
toggleMoreOpen() { this.moreOpen = !this.moreOpen; if (this.moreOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
showToast(msg, type) {
this.toastMsg = msg;
this.toastVisible = true;
var self = this;
setTimeout(function() { self.toastVisible = false; }, 2000);
},
exportPage() { window.showToast && window.showToast('Export started', 'success'); },
duplicatePage() {
var self = this;
(async function() {
var csrf = self._getCsrf();
var r = await fetch('/board/api/pages', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
body: JSON.stringify({title: self.pageTitle + ' (copy)', content: JSON.stringify(self.blocks), content_format: 'blocks'})
});
if (r.ok) { var d = await r.json(); window.location = '/pages/' + d.id; }
})();
},
movePage() { this.moveOpen = true; this._loadWorkspaces(); },
async _loadWorkspaces() {
this.moveLoading = true;
try { var r = await fetch('/api/workspaces'); var d = await r.json(); this.moveWorkspaces = d.workspaces || []; } catch(e) {}
this.moveLoading = false;
},
async doMove(wsId) {
var csrf = this._getCsrf();
await fetch('/board/api/pages/' + this.pid + '/move', {
method: 'PUT', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
body: JSON.stringify({workspace_id: wsId})
});
this.moveOpen = false;
window.showToast && window.showToast('Moved', 'success');
},
deletePage() {
if (!confirm('Move to Trash?')) return;
var self = this;
(async function() {
var csrf = self._getCsrf();
await fetch('/board/api/pages/' + self.pid, {method: 'DELETE', headers: {'X-CSRF-Token': csrf}});
window.location = '/workspaces';
})();
},
publishPage() {
var self = this;
(async function() {
var csrf = self._getCsrf();
await fetch('/pages/' + self.pid + '/publish', {method: 'POST', headers: {'X-CSRF-Token': csrf}});
self.pagePublished = true;
self.publishedUrl = window.location.origin + '/p/' + self.pid;
})();
},
unpublishPage() {
var self = this;
(async function() {
var csrf = self._getCsrf();
await fetch('/pages/' + self.pid + '/publish', {method: 'DELETE', headers: {'X-CSRF-Token': csrf}});
self.pagePublished = false;
self.publishedUrl = '';
})();
},
shareInvite() { if (this.inviteEmail) { this.accessList.push({email: this.inviteEmail, permission: 'editor', permOpen: false}); this.inviteEmail = ''; } },
removeAccess(email) { this.accessList = this.accessList.filter(function(a) { return a.email !== email; }); },
formatDate(d) { try { return new Date(d).toLocaleDateString(); } catch(e) { return d; } },
_getCsrf() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
},
_timeAgo(d) {
try {
var dt = new Date(d);
var now = new Date();
var diff = (now - dt) / 1000;
if (diff < 60) return 'just now';
var mins = Math.floor(diff/60);
if (mins < 60) return mins + 'm ago';
var hours = Math.floor(mins/60);
if (hours < 24) return hours + 'h ago';
var days = Math.floor(hours/24);
if (days < 7) return days + 'd ago';
if (days < 30) return Math.floor(days/7) + 'w ago';
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
} catch(e) { return d; }
}
};
}
</script>
{% endblock %}