feat(database): inline embed + Open button + side peek + title placeholder
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- Page title placeholder: new pages created with empty title, CSS :empty::before shows 'New page' in gray
- Database is now inline embed within page (not full-page replacement)
- Embed block 'collection' renders database table via FlowDeckDB.renderInto()
- Open button in column 1 of each row opens page in side peek panel
- Side peek: iframe loading /pages/{id}?embed=1, close/fullscreen/resize
- DB scripts refactored as reusable DBInstance + global FlowDeckDB API
This commit is contained in:
2026-07-22 16:52:19 -04:00
parent a7289db621
commit c586513b03
7 changed files with 331 additions and 494 deletions
+1 -1
View File
@@ -814,7 +814,7 @@ async def create_page(request: Request, title: str = Query(default=""),
"""Create a new Markdown page, optionally as a sub-page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
page_title = title.strip() if title else "Untitled"
page_title = title.strip() if title else ""
try:
with get_conn() as conn:
# Compute next sort_order for this parent
+16 -23
View File
@@ -86,38 +86,24 @@
<span class="db-col-menu-arrow">›</span>
</div>
<div class="db-col-menu-sub" id="db-col-ai-sub" style="display:none">
<div class="db-col-menu-item sub-item">
<span>✨ Basic</span>
</div>
<div class="db-col-menu-item sub-item"><span>✨ Basic</span></div>
<div class="db-col-menu-subnote">Not available for this property type</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item sub-item">
<span>🤖 Custom Agent</span>
<span class="db-col-menu-tag">New</span>
</div>
<div class="db-col-menu-item sub-item"><span>🤖 Custom Agent</span><span class="db-col-menu-tag">New</span></div>
<div class="db-col-menu-subnote">Uses advanced models to fill properties based on web search and your connected workspace.</div>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item" id="db-col-filter"><span>≡ Filter</span></div>
<div class="db-col-menu-item has-sub" id="db-col-sort">
<span>↕ Sort</span>
<span class="db-col-menu-arrow">›</span>
</div>
<div class="db-col-menu-item has-sub" id="db-col-sort"><span>↕ Sort</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-sort-sub" style="display:none">
<div class="db-col-menu-item sub-item"><span>↑ Sort ascending</span></div>
<div class="db-col-menu-item sub-item"><span>↓ Sort descending</span></div>
</div>
<div class="db-col-menu-item" id="db-col-group"><span>▦ Group</span></div>
<div class="db-col-menu-item has-sub" id="db-col-calc">
<span>Σ Calculate</span>
<span class="db-col-menu-arrow">›</span>
</div>
<div class="db-col-menu-item has-sub" id="db-col-calc"><span>Σ Calculate</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-calc-sub" style="display:none">
<div class="db-col-menu-item sub-item checked"><span>None</span></div>
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-count">
<span>Count</span>
<span class="db-col-menu-arrow">›</span>
</div>
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-count"><span>Count</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-calc-count-sub" style="display:none">
<div class="db-col-menu-item sub-item"><span>Count all</span></div>
<div class="db-col-menu-item sub-item"><span>Count values</span></div>
@@ -125,10 +111,7 @@
<div class="db-col-menu-item sub-item"><span>Count empty</span></div>
<div class="db-col-menu-item sub-item"><span>Count not empty</span></div>
</div>
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-pct">
<span>Percent</span>
<span class="db-col-menu-arrow">›</span>
</div>
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-pct"><span>Percent</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-calc-pct-sub" style="display:none">
<div class="db-col-menu-item sub-item"><span>Percent empty</span></div>
<div class="db-col-menu-item sub-item"><span>Percent not empty</span></div>
@@ -141,4 +124,14 @@
<div class="db-col-menu-item" id="db-col-insert-left"><span>├← Insert left</span></div>
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
</div>
<!-- Side Peek Panel for Database Pages -->
<div id="db-side-peek" class="db-side-peek" style="display:none">
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
<div class="db-side-peek-header">
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
</div>
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
</div>
</div>
+273 -462
View File
@@ -2,21 +2,8 @@
<script>
(function() {
'use strict';
const COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
const PAGE_ID = window.__DB_PAGE_ID || 0;
// ── State ──
const state = {
properties: [],
pages: [],
editingCell: null,
colMenuPropId: null, // which property's context menu is open
colMenuSubOpen: null, // which submenu is open
colMenuIconOn: true, // "Show page icon" toggle
colWidths: {} // propId -> px width (persisted in render but not localstorage for now)
};
// ── SVG Outline Icons for Property Types ──
// ── SVG Outline Icons ──
function propTypeSvg(type) {
const s = 14;
switch(type) {
@@ -43,489 +30,313 @@
}
}
// ── Cell Value ──
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function cellValue(cpage, prop) {
if (!cpage.property_values_json) return '';
let pv;
try {
pv = typeof cpage.property_values_json === 'string'
? JSON.parse(cpage.property_values_json) : cpage.property_values_json;
} catch(e) { return ''; }
try { pv = typeof cpage.property_values_json === 'string' ? JSON.parse(cpage.property_values_json) : cpage.property_values_json; }
catch(e) { return ''; }
const val = pv[String(prop.id)];
if (val === undefined || val === null) {
return prop.prop_type === 'title' ? (cpage.title || '') : '';
}
if (val === undefined || val === null) return prop.prop_type === 'title' ? (cpage.title || '') : '';
if (Array.isArray(val)) return val.join(', ');
return String(val);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── DB Instance (one per container) ──
function DBInstance(containerEl, collectionId, initialData) {
const state = {
properties: initialData ? initialData.properties : [],
pages: initialData ? initialData.pages : [],
editingCell: null,
colMenuPropId: null,
colMenuSubOpen: null,
colMenuIconOn: true,
colWidths: {}
};
// ── Render Table ──
function renderTable() {
const thead = document.getElementById('db-thead');
const tbody = document.getElementById('db-tbody');
if (!thead || !tbody) return;
function getCsrf() { const m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
const props = state.properties;
const pages = state.pages;
function render() {
const props = state.properties;
const pages = state.pages;
// ── Header with resize handles ──
let h = '<tr class="db-header-row">';
h += '<th class="db-th db-th-handle" style="width:32px;min-width:32px"></th>';
for (let i = 0; i < props.length; i++) {
const prop = props[i];
const w = state.colWidths[prop.id] || 200;
h += '<th class="db-th db-th-property" style="min-width:' + w + 'px;width:' + w + 'px" data-prop-id="' + prop.id + '">';
h += '<div class="db-th-content" data-prop-id="' + prop.id + '" data-prop-type="' + prop.prop_type + '" data-prop-name="' + escHtml(prop.name) + '">';
h += '<span class="db-prop-type-icon">' + propTypeSvg(prop.prop_type) + '</span>';
h += '<span class="db-prop-name db-col-title-btn">' + escHtml(prop.name) + '</span>';
// Init col widths
for (const p of props) { if (!state.colWidths[p.id]) state.colWidths[p.id] = 200; }
// Build inner HTML
let h = '<div class="db-view-bar" style="padding:4px 0 8px">';
h += '<div class="db-view-tabs"><button class="db-view-tab active"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> Table</button></div>';
h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>';
h += '</div>';
h += '<div class="db-col-resize-handle" data-prop-id="' + prop.id + '"></div>';
h += '</th>';
}
h += '<th class="db-th db-th-add-prop"><button id="db-add-prop-btn" class="db-add-prop-btn">+ Add property</button></th>';
h += '<th class="db-th" style="width:40px;min-width:40px"></th>'; // open button column
h += '</tr>';
thead.innerHTML = h;
// Bind header events
bindHeaderEvents();
// ── Body: data rows first, then "New Page" at the bottom ──
let b = '';
const totalCols = Math.max(1, props.length);
// Data rows
for (let i = 0; i < pages.length; i++) {
const cp = pages[i];
b += '<tr class="db-row" data-page-id="' + cp.id + '" data-idx="' + i + '">';
b += '<td class="db-td db-td-handle"><span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span></td>';
h += '<div class="db-table-wrapper"><table class="db-table">';
// Header
h += '<thead><tr class="db-header-row">';
h += '<th class="db-th db-th-handle" style="width:32px;min-width:32px"></th>';
for (const prop of props) {
const val = cellValue(cp, prop) || '\u2014';
const cellId = 'cell-' + cp.id + '-' + prop.id;
b += '<td class="db-td db-td-cell" style="min-width:' + (state.colWidths[prop.id] || 200) + 'px">';
b += '<div class="db-cell-content" id="' + cellId + '" data-page-id="' + cp.id + '" data-prop-id="' + prop.id + '">';
b += '<span class="db-cell-value">' + escHtml(val) + '</span>';
b += '</div></td>';
const w = state.colWidths[prop.id] || 200;
h += '<th class="db-th db-th-property" style="min-width:'+w+'px;width:'+w+'px" data-prop-id="'+prop.id+'">';
h += '<div class="db-th-content" data-prop-id="'+prop.id+'" data-prop-type="'+prop.prop_type+'" data-prop-name="'+escHtml(prop.name)+'">';
h += '<span class="db-prop-type-icon">'+propTypeSvg(prop.prop_type)+'</span>';
h += '<span class="db-prop-name db-col-title-btn">'+escHtml(prop.name)+'</span>';
h += '</div><div class="db-col-resize-handle" data-prop-id="'+prop.id+'"></div></th>';
}
b += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden">';
b += '<button class="db-open-btn db-open-page-btn" data-page-id="' + cp.id + '" title="Open">↗</button>';
b += '</td>';
b += '</tr>';
h += '<th class="db-th db-th-add-prop"><button class="db-add-prop-btn db-show-prop-modal-btn">+ Add property</button></th>';
h += '<th class="db-th" style="width:40px;min-width:40px"></th>';
h += '</tr></thead>';
// Body
h += '<tbody>';
for (const cp of pages) {
h += '<tr class="db-row" data-page-id="'+cp.id+'">';
h += '<td class="db-td db-td-handle"><span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span></td>';
for (const prop of props) {
const val = cellValue(cp, prop) || '\u2014';
const isNameCol = prop.prop_type === 'title';
h += '<td class="db-td db-td-cell" style="min-width:'+(state.colWidths[prop.id]||200)+'px">';
h += '<div class="db-cell-content" data-page-id="'+cp.id+'" data-prop-id="'+prop.id+'">';
h += '<span class="db-cell-value">'+escHtml(val)+'</span>';
// OPEN button inline in first (title) column
if (isNameCol) {
h += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+cp.id+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
h += '</div></td>';
}
h += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden"><button class="db-open-btn db-open-page-btn" data-page-id="'+cp.id+'" title="Open">↗</button></td>';
h += '</tr>';
}
// Empty rows + New Page at bottom
const emptyNeeded = Math.max(0, 4 - pages.length);
for (let i = 0; i < emptyNeeded; i++) {
h += '<tr class="db-row db-row-empty"><td class="db-td db-td-handle"></td>';
for (let j = 0; j < props.length; j++) h += '<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>';
h += '<td class="db-td db-td-open"></td></tr>';
}
h += '<tr class="db-row db-row-new"><td class="db-td db-td-handle"><span class="db-plus-icon">+</span></td>';
h += '<td class="db-td db-td-name" colspan="'+(Math.max(1,props.length)+1)+'"><button class="db-new-page-btn db-add-page-btn">New page</button></td></tr>';
h += '</tbody></table></div>';
containerEl.innerHTML = h;
// Bind events
bindEvents();
}
// Empty filler rows (min 4 data rows visible)
const emptyNeeded = Math.max(0, 4 - pages.length);
for (let i = 0; i < emptyNeeded; i++) {
b += '<tr class="db-row db-row-empty">';
b += '<td class="db-td db-td-handle"></td>';
for (let j = 0; j < props.length; j++) {
b += '<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>';
}
b += '<td class="db-td db-td-open"></td>';
b += '</tr>';
function bindEvents() {
// New page buttons
containerEl.querySelectorAll('.db-add-page-btn').forEach(b => b.onclick = addNewPage);
// Cell edit
containerEl.querySelectorAll('.db-cell-content').forEach(el => {
el.onclick = function(e) {
if (state.editingCell || e.target.closest('.db-open-btn')) return;
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
};
});
// Open buttons
containerEl.querySelectorAll('.db-open-page-btn').forEach(btn => {
btn.onclick = function(e) {
e.stopPropagation();
openPageInSidePeek(parseInt(this.dataset.pageId));
};
});
// Hover
containerEl.querySelectorAll('.db-row').forEach(row => {
row.onmouseenter = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'visible'); };
row.onmouseleave = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'hidden'); };
});
// Header context menu
containerEl.querySelectorAll('.db-th-content').forEach(el => {
el.onclick = function(e) { e.stopPropagation(); showColMenu(parseInt(this.dataset.propId), this.dataset.propType, this.dataset.propName, this); };
});
// Column resize
containerEl.querySelectorAll('.db-col-resize-handle').forEach(handle => {
handle.onpointerdown = function(e) {
e.preventDefault(); e.stopPropagation();
const propId = parseInt(this.dataset.propId);
const th = this.parentElement;
const thLeft = th.getBoundingClientRect().left;
function onMove(ev) {
const newW = Math.max(60, ev.clientX - thLeft);
state.colWidths[propId] = newW;
th.style.width = newW+'px'; th.style.minWidth = newW+'px';
const colIdx = state.properties.findIndex(p => p.id === propId);
if (colIdx >= 0) {
containerEl.querySelectorAll('.db-row').forEach(row => {
const cells = row.querySelectorAll('.db-td-cell');
if (cells[colIdx]) cells[colIdx].style.minWidth = newW+'px';
});
}
}
function onUp() { document.removeEventListener('pointermove',onMove); document.removeEventListener('pointerup',onUp); document.body.style.cursor=''; document.body.style.userSelect=''; }
document.body.style.cursor='col-resize'; document.body.style.userSelect='none';
document.addEventListener('pointermove',onMove); document.addEventListener('pointerup',onUp);
};
});
// Add property modal bind
containerEl.querySelector('.db-show-prop-modal-btn').onclick = function() { showPropModal(); };
}
// ── New Page row at the BOTTOM ──
b += '<tr class="db-row db-row-new">';
b += '<td class="db-td db-td-handle"><span class="db-plus-icon">+</span></td>';
b += '<td class="db-td db-td-name" colspan="' + (totalCols + 1) + '">';
b += '<button id="db-new-page-btn" class="db-new-page-btn">New page</button>';
b += '</td></tr>';
function startEditCell(pageId, propId, cellEl) {
const cp = state.pages.find(p => p.id === pageId);
const prop = state.properties.find(p => p.id === propId);
if (!cp || !prop) return;
const val = cellValue(cp, prop);
const input = document.createElement('input');
input.type='text'; input.className='db-cell-input'; input.value=val;
cellEl.innerHTML=''; cellEl.appendChild(input); cellEl.classList.add('db-cell-editing'); input.focus();
state.editingCell = {pageId,propId,input,cellEl};
input.onkeydown = function(e) { if(e.key==='Enter') commitEditCell(); else if(e.key==='Escape') cancelEditCell(); };
input.onblur = function() { commitEditCell(); };
}
tbody.innerHTML = b;
bindTableEvents();
function commitEditCell() {
if(!state.editingCell) return;
const {pageId,propId,input,cellEl}=state.editingCell; state.editingCell=null;
const val=input.value;
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
if(!cp||!prop) return;
let pv; try{pv=typeof cp.property_values_json==='string'?JSON.parse(cp.property_values_json):cp.property_values_json}catch(e){pv={};}
pv[String(propId)]=val; cp.property_values_json=pv;
if(prop.prop_type==='title') cp.title=val;
cellEl.classList.remove('db-cell-editing');
const isNameCol = prop.prop_type === 'title';
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
if (isNameCol) {
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
fetch('/db/pages/'+pageId+'/api',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:cp.title,properties:pv})}).catch(function(e){});
}
function cancelEditCell() {
if(!state.editingCell) return;
const {pageId,propId,cellEl}=state.editingCell; state.editingCell=null;
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
if(!cp||!prop) return;
const val=cellValue(cp,prop);
cellEl.classList.remove('db-cell-editing');
const isNameCol = prop.prop_type === 'title';
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
if (isNameCol) {
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
}
async function addNewPage() {
try {
const r = await fetch('/api/collections/'+collectionId+'/pages',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:''})});
const d = await r.json();
if(d.id) { state.pages.push({id:d.id,title:'',position:state.pages.length,icon:'file',collection_id:collectionId,property_values_json:{}}); render(); }
} catch(e) { console.error('[DB] addNewPage error:', e); }
}
// ── Property modal (uses global modal from page) ──
function showPropModal() {
const modal = document.getElementById('db-prop-modal');
if (!modal) return;
modal._dbInstance = this;
modal.style.display = 'flex';
document.getElementById('db-prop-name-input').value = '';
document.getElementById('db-prop-name-input').focus();
// Store ref to this instance for the create handler
modal._collectionId = collectionId;
modal._onPropCreated = function(newProp) { state.properties.push(newProp); render(); };
}
// ── Column context menu ──
function showColMenu(propId, propType, propName, anchor) {
const menu = document.getElementById('db-col-menu');
if (!menu) return;
state.colMenuPropId = propId;
document.getElementById('db-col-menu-icon').innerHTML = propTypeSvg(propType);
const nameEl = document.getElementById('db-col-menu-name');
nameEl.textContent = propName;
const rect = anchor.getBoundingClientRect();
menu.style.top = (rect.bottom+4)+'px'; menu.style.left = rect.left+'px'; menu.style.display = 'block';
setTimeout(() => document.addEventListener('click', function onDoc(e) {
if(!menu.contains(e.target)) { menu.style.display='none'; document.removeEventListener('click',onDoc); }
}, {once:true}), 0);
}
// ── Init ──
if (!initialData) {
fetch('/api/collections/'+collectionId+'/table-data').then(r=>r.json()).then(d => {
state.properties = d.properties||[]; state.pages = d.pages||[]; render();
}).catch(e=>console.error('[DB] load error:',e));
} else { render(); }
}
// ── Header Events (resize + context menu) ──
function bindHeaderEvents() {
const thead = document.getElementById('db-thead');
if (!thead) return;
// ── Global API ──
window.FlowDeckDB = {
/** Render a database table into a container element. */
renderInto: function(containerEl, collectionId, initialData) {
new DBInstance(containerEl, collectionId, initialData);
}
};
// Column title click → context menu
thead.querySelectorAll('.db-th-content').forEach(el => {
el.onclick = function(e) {
e.stopPropagation();
const propId = parseInt(this.dataset.propId);
const propType = this.dataset.propType;
const propName = this.dataset.propName;
showColMenu(propId, propType, propName, this);
};
});
// Add Property button
const addBtn = document.getElementById('db-add-prop-btn');
if (addBtn) addBtn.onclick = showPropModal;
// Column resize
thead.querySelectorAll('.db-col-resize-handle').forEach(handle => {
handle.onpointerdown = function(e) {
e.preventDefault();
e.stopPropagation();
const propId = parseInt(this.dataset.propId);
const th = this.parentElement;
const thLeft = th.getBoundingClientRect().left;
// ── Full-page init (when page_editor_collection.html is used) ──
const PAGE_COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
if (PAGE_COLLECTION_ID > 0) {
const container = document.getElementById('db-table-container');
if (container) {
{% if collection_data %}
new DBInstance(container, PAGE_COLLECTION_ID, {
properties: {{ collection_data.properties | tojson }},
pages: {{ collection_data.pages | tojson }}
});
{% else %}
new DBInstance(container, PAGE_COLLECTION_ID, null);
{% endif %}
}
}
// ── Global Side Peek for Database Pages ──
function openPageInSidePeek(pageId) {
var panel = document.getElementById('db-side-peek');
var iframe = document.getElementById('db-side-peek-iframe');
if (!panel || !iframe) return;
iframe.src = '/pages/' + pageId + '?embed=1';
panel.style.display = 'flex';
}
function closeSidePeek() {
var panel = document.getElementById('db-side-peek');
if (panel) { panel.style.display = 'none'; }
var iframe = document.getElementById('db-side-peek-iframe');
if (iframe) { iframe.src = ''; }
}
// Bind close button on DOM ready
setTimeout(function() {
var closeBtn = document.getElementById('db-side-peek-close');
if (closeBtn) closeBtn.onclick = closeSidePeek;
var fullBtn = document.getElementById('db-side-peek-full');
if (fullBtn) fullBtn.onclick = function() {
var iframe = document.getElementById('db-side-peek-iframe');
if (iframe && iframe.src) window.open(iframe.src.replace('?embed=1',''), '_blank');
};
// Resize handle
var resizeHandle = document.getElementById('db-side-peek-resize');
var panel = document.getElementById('db-side-peek');
if (resizeHandle && panel) {
resizeHandle.onpointerdown = function(e) {
e.preventDefault(); e.stopPropagation();
var startX = e.clientX;
var startW = panel.offsetWidth;
function onMove(ev) {
// Calculate width directly from mouse position relative to th left edge
const newW = Math.max(60, ev.clientX - thLeft);
state.colWidths[propId] = newW;
th.style.width = newW + 'px';
th.style.minWidth = newW + 'px';
const colIdx = state.properties.findIndex(p => p.id === propId);
if (colIdx >= 0) {
document.querySelectorAll('#db-tbody .db-row').forEach(row => {
const cells = row.querySelectorAll('.db-td-cell');
if (cells[colIdx]) cells[colIdx].style.minWidth = newW + 'px';
});
}
var newW = Math.max(300, Math.min(window.innerWidth * 0.9, startW - (ev.clientX - startX)));
panel.style.width = newW + 'px';
}
function onUp() {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
};
});
}
function bindTableEvents() {
const tbody = document.getElementById('db-tbody');
if (!tbody) return;
document.getElementById('db-new-page-btn').onclick = addNewPage;
tbody.querySelectorAll('.db-cell-content').forEach(el => {
el.onclick = function(e) {
if (state.editingCell) return;
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
};
});
tbody.querySelectorAll('.db-open-page-btn').forEach(btn => {
btn.onclick = function(e) {
e.stopPropagation();
window.location.href = '/pages/' + parseInt(this.dataset.pageId);
};
});
tbody.querySelectorAll('.db-row').forEach(row => {
row.onmouseenter = function() {
this.querySelectorAll('.db-hover-only').forEach(el => { el.style.visibility = 'visible'; });
};
row.onmouseleave = function() {
this.querySelectorAll('.db-hover-only').forEach(el => { el.style.visibility = 'hidden'; });
};
});
}
// ── Column Context Menu ──
function showColMenu(propId, propType, propName, anchor) {
hideColMenu();
const menu = document.getElementById('db-col-menu');
if (!menu) return;
state.colMenuPropId = propId;
state.colMenuIconOn = true;
// Set header
document.getElementById('db-col-menu-icon').innerHTML = propTypeSvg(propType);
const nameEl = document.getElementById('db-col-menu-name');
nameEl.textContent = propName;
nameEl.contentEditable = 'false';
nameEl.style.cursor = 'text';
nameEl.style.border = 'none';
nameEl.style.outline = 'none';
nameEl.style.padding = '0';
nameEl.style.background = 'transparent';
nameEl.title = 'Click to rename';
// Click on name to edit
nameEl.onclick = function(e) {
e.stopPropagation();
if (nameEl.contentEditable === 'true') return;
nameEl.contentEditable = 'true';
nameEl.style.border = '1px solid var(--accent, #2383E2)';
nameEl.style.borderRadius = '3px';
nameEl.style.padding = '2px 4px';
nameEl.style.outline = 'none';
nameEl.focus();
// Select all text
const range = document.createRange();
range.selectNodeContents(nameEl);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
// Blur/Enter → save rename
nameEl.onblur = function() { commitColRename(propId); };
nameEl.onkeydown = function(e) {
if (e.key === 'Enter') { e.preventDefault(); commitColRename(propId); }
else if (e.key === 'Escape') { cancelColRename(propId, propName); }
};
document.getElementById('db-col-menu-info').textContent = 'ⓘ';
// Position near the anchor
const rect = anchor.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = rect.left + 'px';
menu.style.display = 'block';
// Close on outside click
setTimeout(() => {
document.addEventListener('click', onDocClickForMenu, { once: true });
}, 0);
}
function onDocClickForMenu(e) {
const menu = document.getElementById('db-col-menu');
if (menu && !menu.contains(e.target)) {
hideColMenu();
}
}
function hideColMenu() {
const menu = document.getElementById('db-col-menu');
if (menu) {
menu.style.display = 'none';
menu.querySelectorAll('.db-col-menu-sub').forEach(s => s.style.display = 'none');
}
state.colMenuPropId = null;
state.colMenuSubOpen = null;
}
async function commitColRename(propId) {
const nameEl = document.getElementById('db-col-menu-name');
if (!nameEl) return;
const newName = nameEl.textContent.trim();
if (!newName) return;
nameEl.contentEditable = 'false';
nameEl.style.border = 'none';
nameEl.style.borderRadius = '0';
nameEl.style.padding = '0';
const prop = state.properties.find(p => p.id === propId);
if (!prop || prop.name === newName) return;
// Update local state
prop.name = newName;
// Update header in table
renderTable();
// If menu still open, update its display
if (state.colMenuPropId === propId) {
nameEl.textContent = newName;
}
// Persist to server
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
try {
await fetch('/db/properties/' + propId + '/api', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ name: newName })
});
} catch(e) { console.error('[DB] rename property error:', e); }
}
function cancelColRename(propId, originalName) {
const nameEl = document.getElementById('db-col-menu-name');
if (!nameEl) return;
nameEl.contentEditable = 'false';
nameEl.textContent = originalName;
nameEl.style.border = 'none';
nameEl.style.borderRadius = '0';
nameEl.style.padding = '0';
}
function toggleColSubmenu(subId) {
const sub = document.getElementById(subId);
if (!sub) return;
const isOpen = sub.style.display === 'block';
// Close all submenus first
document.querySelectorAll('#db-col-menu .db-col-menu-sub').forEach(s => s.style.display = 'none');
if (!isOpen) {
sub.style.display = 'block';
state.colMenuSubOpen = subId;
} else {
state.colMenuSubOpen = null;
}
}
function bindColMenu() {
// Show page icon toggle
const iconItem = document.getElementById('db-col-show-icon');
if (iconItem) iconItem.onclick = function() {
state.colMenuIconOn = !state.colMenuIconOn;
const t = document.getElementById('db-col-icon-toggle');
if (t) {
t.textContent = state.colMenuIconOn ? '● ON' : '○ OFF';
t.className = 'db-col-menu-toggle ' + (state.colMenuIconOn ? 'on' : 'off');
}
};
// Submenu toggles
const subTriggers = {
'db-col-ai-autofill': 'db-col-ai-sub',
'db-col-sort': 'db-col-sort-sub',
'db-col-calc': 'db-col-calc-sub',
'db-col-calc-count': 'db-col-calc-count-sub',
'db-col-calc-pct': 'db-col-calc-pct-sub'
};
for (const [triggerId, subId] of Object.entries(subTriggers)) {
const trigger = document.getElementById(triggerId);
if (trigger) {
trigger.onclick = function(e) {
e.stopPropagation();
toggleColSubmenu(subId);
};
}
}
// Simple action items (close menu)
['db-col-filter','db-col-group','db-col-freeze','db-col-wrap','db-col-insert-left','db-col-insert-right'].forEach(id => {
const el = document.getElementById(id);
if (el) el.onclick = hideColMenu;
});
}
// ── Cell Editing ──
function startEditCell(pageId, propId, cellEl) {
const cp = state.pages.find(p => p.id === pageId);
const prop = state.properties.find(p => p.id === propId);
if (!cp || !prop) return;
const currentVal = cellValue(cp, prop);
const input = document.createElement('input');
input.type = 'text'; input.className = 'db-cell-input'; input.value = currentVal;
cellEl.innerHTML = ''; cellEl.appendChild(input); cellEl.classList.add('db-cell-editing');
input.focus();
state.editingCell = { pageId, propId, input, cellEl };
input.onkeydown = function(e) {
if (e.key === 'Enter') commitEditCell();
else if (e.key === 'Escape') cancelEditCell();
};
input.onblur = function() { commitEditCell(); };
}
function commitEditCell() {
if (!state.editingCell) return;
const { pageId, propId, input, cellEl } = state.editingCell;
const val = input.value; state.editingCell = null;
const cp = state.pages.find(p => p.id === pageId);
const prop = state.properties.find(p => p.id === propId);
if (!cp || !prop) return;
let pv;
try { pv = typeof cp.property_values_json === 'string' ? JSON.parse(cp.property_values_json) : cp.property_values_json; }
catch(e) { pv = {}; }
pv[String(propId)] = val; cp.property_values_json = pv;
if (prop.prop_type === 'title') cp.title = val;
cellEl.classList.remove('db-cell-editing');
cellEl.innerHTML = '<span class="db-cell-value">' + escHtml(val || '\u2014') + '</span>';
cellEl.onclick = function(e) { if (!state.editingCell) startEditCell(pageId, propId, cellEl); };
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/db/pages/' + pageId + '/api', {
method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ title: cp.title, properties: pv })
}).catch(function(e) { console.error('[DB] commitEditCell error:', e); });
}
function cancelEditCell() {
if (!state.editingCell) return;
const { pageId, propId, cellEl } = state.editingCell; state.editingCell = null;
const cp = state.pages.find(p => p.id === pageId);
const prop = state.properties.find(p => p.id === propId);
if (!cp || !prop) return;
const val = cellValue(cp, prop);
cellEl.classList.remove('db-cell-editing');
cellEl.innerHTML = '<span class="db-cell-value">' + escHtml(val || '\u2014') + '</span>';
cellEl.onclick = function(e) { if (!state.editingCell) startEditCell(pageId, propId, cellEl); };
}
// ── Add New Page ──
async function addNewPage() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
try {
const r = await fetch('/api/collections/' + COLLECTION_ID + '/pages', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ title: 'New page' })
});
const d = await r.json();
if (d.id) {
// Insert at end (position = state.pages.length)
state.pages.push({ id: d.id, title: d.title, position: state.pages.length, icon: 'file', collection_id: COLLECTION_ID, property_values_json: {} });
renderTable();
console.log('[DB] New page created:', d.id);
}
} catch(e) { console.error('[DB] addNewPage error:', e); }
}
// ── Property Modal ──
function showPropModal() {
document.getElementById('db-prop-modal').style.display = 'flex';
document.getElementById('db-prop-name-input').value = '';
document.getElementById('db-prop-name-input').focus();
}
function hidePropModal() { document.getElementById('db-prop-modal').style.display = 'none'; }
async function createProperty() {
const nameInput = document.getElementById('db-prop-name-input');
const typeSelect = document.getElementById('db-prop-type-select');
const name = nameInput.value.trim();
if (!name) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
try {
const r = await fetch('/db/' + COLLECTION_ID + '/properties/api', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ name: name, prop_type: typeSelect.value })
});
const d = await r.json();
if (d.id) { state.properties.push(d); renderTable(); hidePropModal(); console.log('[DB] Property created:', d.name); }
} catch(e) { console.error('[DB] createProperty error:', e); }
}
// ── View Dropdown ──
function bindUI() {
document.getElementById('db-view-add-btn').onclick = function(e) {
e.stopPropagation();
const dd = document.getElementById('db-add-view-dropdown');
dd.style.display = dd.style.display === 'none' ? 'block' : 'none';
};
document.addEventListener('click', function() {
document.getElementById('db-add-view-dropdown').style.display = 'none';
});
document.getElementById('db-prop-modal-close').onclick = hidePropModal;
document.getElementById('db-prop-cancel').onclick = hidePropModal;
document.getElementById('db-prop-create').onclick = createProperty;
document.getElementById('db-prop-modal').onclick = function(e) { if (e.target === this) hidePropModal(); };
document.getElementById('db-prop-name-input').onkeydown = function(e) { if (e.key === 'Enter') createProperty(); };
document.getElementById('db-new-btn').onclick = addNewPage;
bindColMenu();
}
// ── Load Data ──
async function loadData() {
{% if collection_data %}
state.properties = {{ collection_data.properties | tojson }};
state.pages = {{ collection_data.pages | tojson }};
{% else %}
try {
const r = await fetch('/api/collections/' + COLLECTION_ID + '/table-data');
const d = await r.json();
state.properties = d.properties || []; state.pages = d.pages || [];
} catch(e) { console.error('[DB] loadData error:', e); }
{% endif %}
// Init column widths
for (const p of state.properties) { state.colWidths[p.id] = 200; }
console.log('[DB-Table] Init: collection_id=' + COLLECTION_ID, state.properties.length + ' props, ' + state.pages.length + ' pages');
renderTable(); bindUI();
}
if (COLLECTION_ID > 0) { loadData(); }
else { console.warn('[DB-Table] No collection_id'); }
}, 100);
})();
</script>
+9 -7
View File
@@ -55,6 +55,7 @@
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
if(b.type==='embed'){
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
if(b.embed_type==='collection')return `<div class="block-wrapper"><div class="block-content block-embed-collection" id="inline-db-${b.collection_id}" style="padding:0;min-height:200px"><div style="padding:40px;text-align:center;color:var(--text-dim)">Loading database...</div></div><script>setTimeout(function(){var el=document.getElementById('inline-db-${b.collection_id}');if(el&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,${b.collection_id},null);},50);</script></div>`;
if(b.embed_type==='pdf')return `<div class="block-wrapper"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;" onerror="this.style.display='none';this.nextElementSibling.style.display='block';"></iframe><div style="display:none;text-align:center;padding:32px;color:var(--text-secondary);"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><p style="margin-top:8px;">Failed to load PDF</p><a href="${b.src||''}" target="_blank" style="color:var(--accent);">Open in new tab →</a></div></div></div>`;
if(b.embed_type==='download'){
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
@@ -322,22 +323,23 @@
},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
// ── v4.5.0+: Get Started actions ──
inlineDBs:[],
// ── v4.6.0: Get Started actions ──
async createDatabase(){
const name=prompt('Database name:',this.pageTitle||'New Database');
if(!name||!name.trim())return;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
try{
const r=await fetch(`/api/pages/${this.pid}/convert-to-database`,{
// Create collection via API
const r=await fetch('/db/api',{
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
body:JSON.stringify({name:name.trim()})
});
const d=await r.json();
if(d.status==='converted'){
this.showToast('Database created — reloading...');
// Reload to show the database table view
setTimeout(()=>{window.location.href=d.view_url;},500);
if(d.id){
// Add inline database embed block to the page
this.blocks.push(this.mkB('embed','',{embed_type:'collection',collection_id:d.id}));
this.render(); this.dirty=true; this.autoSave();
this.showToast('Database created: '+d.name);
}else{
this.showToast('Failed to create database');
}
+1 -1
View File
@@ -756,7 +756,7 @@
// ── Workspace context → create workspace item ──
if (ctx.wsId > 0) {
var body = { name: 'Untitled', type: 'page' };
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
fetch('/api/local-workspace/items', {
+1
View File
@@ -8,4 +8,5 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% include "_page_editor_content.html" %}
{% endblock %} {% block scripts %}
{% include "_page_editor_scripts.html" %}
{% include "_database_table_scripts.html" %}
{% endblock %}
+30
View File
@@ -3952,3 +3952,33 @@ button.breadcrumb-current { max-width: 260px; }
.db-th-property { min-width: 120px; }
.db-td-cell { min-width: 120px; }
}
/* ── Database Side Peek Panel ── */
.db-side-peek {
position: fixed; top: 0; right: 0;
width: 560px; height: 100vh;
background: var(--bg-primary);
box-shadow: -4px 0 20px rgba(0,0,0,0.12);
z-index: 900;
display: flex; flex-direction: column;
}
.db-side-peek-resize {
position: absolute; top: 0; left: 0;
width: 6px; height: 100%;
cursor: col-resize;
z-index: 2;
}
.db-side-peek-resize:hover { background: var(--accent, #2383E2); }
.db-side-peek-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.db-side-peek-close, .db-side-peek-full {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px;
border: none; background: transparent; cursor: pointer;
font-size: 18px; color: var(--text-dim);
}
.db-side-peek-close:hover, .db-side-peek-full:hover { background: var(--bg-hover); }
.db-side-peek iframe { flex: 1; }