Files
flowdeck/app/templates/_database_table_scripts.html
T
bruno c586513b03
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
feat(database): inline embed + Open button + side peek + title placeholder
- 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
2026-07-22 16:52:19 -04:00

343 lines
21 KiB
HTML

<!-- ═══════════ Database Table Scripts — Vanilla JS ═══════════ -->
<script>
(function() {
'use strict';
// ── SVG Outline Icons ──
function propTypeSvg(type) {
const s = 14;
switch(type) {
case 'text': case 'title':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
case 'number':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="4" x2="18" y2="20"/><line x1="18" y1="4" x2="6" y2="20"/></svg>';
case 'select':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/></svg>';
case 'multi_select':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><rect x="2" y="5" width="4" height="2" rx="0.5"/><rect x="2" y="11" width="4" height="2" rx="0.5"/><rect x="2" y="17" width="4" height="2" rx="0.5"/></svg>';
case 'date':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
case 'checkbox':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
case 'url':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
case 'email':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
case 'phone':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
default:
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
}
}
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 ''; }
const val = pv[String(prop.id)];
if (val === undefined || val === null) return prop.prop_type === 'title' ? (cpage.title || '') : '';
if (Array.isArray(val)) return val.join(', ');
return String(val);
}
// ── 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: {}
};
function getCsrf() { const m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
function render() {
const props = state.properties;
const pages = state.pages;
// 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-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 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>';
}
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();
}
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(); };
}
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(); };
}
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(); }
}
// ── Global API ──
window.FlowDeckDB = {
/** Render a database table into a container element. */
renderInto: function(containerEl, collectionId, initialData) {
new DBInstance(containerEl, collectionId, initialData);
}
};
// ── 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) {
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.cursor = 'col-resize';
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
};
}
}, 100);
})();
</script>