feat(database): column resize, context menu, SVG icons, move New Page to bottom
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- Column resize: drag handle between headers to resize column width
- SVG outline icons per property type in header (Aa, #, ☰, 📅, ☑, 🔗, ✉, 📞)
- Header context menu on click: Show page icon toggle, AI Autofill, Filter,
  Sort (asc/desc), Group, Calculate (None/Count/Percent), Freeze, Wrap,
  Insert left/right
- Submenus for AI Autofill, Sort, Calculate with nested options
- New Page button moved below last data row (bottom of table)
- New rows inserted at end of table
This commit is contained in:
2026-07-22 16:02:31 -04:00
parent ea81e85848
commit 461492eede
3 changed files with 417 additions and 158 deletions
+75
View File
@@ -66,4 +66,79 @@
</div>
</div>
</div>
<!-- Column Header Context Menu -->
<div id="db-col-menu" class="db-col-menu" style="display:none">
<div class="db-col-menu-header">
<span class="db-col-menu-icon" id="db-col-menu-icon">Aa</span>
<span class="db-col-menu-name" id="db-col-menu-name">Name</span>
<span class="db-col-menu-info" id="db-col-menu-info"></span>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item" id="db-col-show-icon">
<span>😊 Show page icon</span>
<span class="db-col-menu-toggle on" id="db-col-icon-toggle">● ON</span>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item has-sub" id="db-col-ai-autofill">
<span>✨ AI Autofill</span>
<span class="db-col-menu-hint">Now with agents</span>
<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-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-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-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-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-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>
<div class="db-col-menu-item sub-item"><span>Count unique values</span></div>
<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-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>
</div>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item" id="db-col-freeze"><span>🧊 Freeze</span></div>
<div class="db-col-menu-item" id="db-col-wrap"><span>📝 Wrap content</span></div>
<div class="db-col-menu-sep"></div>
<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>
</div>
+254 -158
View File
@@ -1,4 +1,4 @@
<!-- ═══════════ Database Table Scripts — Vanilla JS (no Alpine) ═══════════ -->
<!-- ═══════════ Database Table Scripts — Vanilla JS ═══════════ -->
<script>
(function() {
'use strict';
@@ -9,23 +9,47 @@
const state = {
properties: [],
pages: [],
editingCell: null // {pageId, propId, input}
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)
};
// ── Icons helper (mirrors fd_icon on server side) ──
const propTypeIcons = {
title: 'Aa', text: 'Aa', number: '#', select: '☰',
multi_select: '☰', date: '📅', checkbox: '☑',
url: '🔗', email: '✉', phone: '📞'
};
// ── SVG Outline Icons for Property Types ──
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>';
}
}
// ── Cell Value ──
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;
? JSON.parse(cpage.property_values_json) : cpage.property_values_json;
} catch(e) { return ''; }
const val = pv[String(prop.id)];
if (val === undefined || val === null) {
@@ -35,7 +59,11 @@
return String(val);
}
// ── Render ──
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Render Table ──
function renderTable() {
const thead = document.getElementById('db-thead');
const tbody = document.getElementById('db-tbody');
@@ -44,57 +72,52 @@
const props = state.properties;
const pages = state.pages;
// Header
// ── Header with resize handles ──
let h = '<tr class="db-header-row">';
h += '<th class="db-th db-th-handle" style="width:32px"></th>';
for (const prop of props) {
h += '<th class="db-th db-th-property"><div class="db-th-content">';
h += '<span class="db-prop-type-icon">' + (propTypeIcons[prop.prop_type] || 'Aa') + '</span>';
h += '<span class="db-prop-name">' + escHtml(prop.name) + '</span>';
h += '</div></th>';
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>';
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 Add Property
const addBtn = document.getElementById('db-add-prop-btn');
if (addBtn) addBtn.onclick = showPropModal;
// Bind header events
bindHeaderEvents();
// Body
// ── Body: data rows first, then "New Page" at the bottom ──
let b = '';
// New Page row
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="' + Math.max(1, props.length + 1) + '">';
b += '<button id="db-new-page-btn" class="db-new-page-btn">New page</button>';
b += '</td></tr>';
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 + '">';
// Drag handle
b += '<td class="db-td db-td-handle">';
b += '<span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span>';
b += '</td>';
// Cells
b += '<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 cellId = 'cell-' + cp.id + '-' + prop.id;
b += '<td class="db-td db-td-cell">';
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>';
}
// Open button (hover only)
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>';
}
// Empty rows (fill to 5 total: 1 New Page + data + empty)
// 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">';
@@ -106,44 +129,98 @@
b += '</tr>';
}
tbody.innerHTML = b;
// ── 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>';
// Bind events after render
tbody.innerHTML = b;
bindTableEvents();
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
// ── Header Events (resize + context menu) ──
function bindHeaderEvents() {
const thead = document.getElementById('db-thead');
if (!thead) return;
// 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();
const propId = parseInt(this.dataset.propId);
const th = this.parentElement;
const startX = e.clientX;
const startW = th.offsetWidth;
function onMove(ev) {
const diff = ev.clientX - startX;
const newW = Math.max(60, startW + diff);
state.colWidths[propId] = newW;
// Update header
th.style.width = newW + 'px';
th.style.minWidth = newW + 'px';
// Update all cells in this column
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';
}
});
}
}
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;
// New Page button
const newBtn = document.getElementById('db-new-page-btn');
if (newBtn) newBtn.onclick = addNewPage;
document.getElementById('db-new-page-btn').onclick = addNewPage;
// Cell click → start editing
tbody.querySelectorAll('.db-cell-content').forEach(el => {
el.onclick = function(e) {
if (state.editingCell) return;
const pageId = parseInt(this.dataset.pageId);
const propId = parseInt(this.dataset.propId);
startEditCell(pageId, propId, this);
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
};
});
// Open buttons
tbody.querySelectorAll('.db-open-page-btn').forEach(btn => {
btn.onclick = function(e) {
e.stopPropagation();
const pageId = parseInt(this.dataset.pageId);
window.location.href = '/pages/' + pageId;
window.location.href = '/pages/' + parseInt(this.dataset.pageId);
};
});
// Row hover effects
tbody.querySelectorAll('.db-row').forEach(row => {
row.onmouseenter = function() {
this.querySelectorAll('.db-hover-only').forEach(el => { el.style.visibility = 'visible'; });
@@ -154,31 +231,115 @@
});
}
// ── 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);
document.getElementById('db-col-menu-name').textContent = 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';
// Hide all submenus
menu.querySelectorAll('.db-col-menu-sub').forEach(s => s.style.display = 'none');
}
state.colMenuPropId = null;
state.colMenuSubOpen = null;
}
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 span = cellEl.querySelector('.db-cell-value');
const currentVal = cellValue(cp, prop);
const input = document.createElement('input');
input.type = 'text';
input.className = 'db-cell-input';
input.value = currentVal;
input.style.cssText = 'width:100%;padding:3px 6px;border:1px solid #2383E2;border-radius:3px;font-size:14px;font-family:var(--font-sans);color:var(--text-primary);background:var(--bg-primary);outline:none;box-sizing:border-box;';
cellEl.innerHTML = '';
cellEl.appendChild(input);
cellEl.classList.add('db-cell-editing');
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(); }
if (e.key === 'Enter') commitEditCell();
else if (e.key === 'Escape') cancelEditCell();
};
input.onblur = function() { commitEditCell(); };
}
@@ -186,60 +347,35 @@
function commitEditCell() {
if (!state.editingCell) return;
const { pageId, propId, input, cellEl } = state.editingCell;
const val = input.value;
state.editingCell = null;
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;
// Update local state
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; }
// Re-render cell
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) return;
startEditCell(pageId, propId, cellEl);
};
// Persist to server
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] : ''
},
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 { 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) return;
startEditCell(pageId, propId, cellEl);
};
cellEl.onclick = function(e) { if (!state.editingCell) startEditCell(pageId, propId, cellEl); };
}
// ── Add New Page ──
@@ -247,26 +383,17 @@
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] : ''
},
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) {
state.pages.push({
id: d.id, title: d.title, position: d.position,
icon: 'file', collection_id: COLLECTION_ID,
property_values_json: {}
});
// 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);
}
} catch(e) { console.error('[DB] addNewPage error:', e); }
}
// ── Property Modal ──
@@ -275,42 +402,25 @@
document.getElementById('db-prop-name-input').value = '';
document.getElementById('db-prop-name-input').focus();
}
function hidePropModal() {
document.getElementById('db-prop-modal').style.display = 'none';
}
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] : ''
},
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);
}
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() {
// Add view dropdown toggle
document.getElementById('db-view-add-btn').onclick = function(e) {
e.stopPropagation();
const dd = document.getElementById('db-add-view-dropdown');
@@ -319,48 +429,34 @@
document.addEventListener('click', function() {
document.getElementById('db-add-view-dropdown').style.display = 'none';
});
// Property modal
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();
};
// New button (top right)
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 %}
// Preloaded from server
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);
}
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();
renderTable(); bindUI();
}
// ── Start ──
if (COLLECTION_ID > 0) {
loadData();
} else {
console.warn('[DB-Table] No collection_id — cannot load database table');
}
if (COLLECTION_ID > 0) { loadData(); }
else { console.warn('[DB-Table] No collection_id'); }
})();
</script>
+88
View File
@@ -3858,6 +3858,94 @@ button.breadcrumb-current { max-width: 260px; }
}
.db-btn-create:hover { opacity: 0.9; }
/* ── Column Resize Handle ── */
.db-col-resize-handle {
position: absolute;
top: 0; right: 0;
width: 5px; height: 100%;
cursor: col-resize;
z-index: 2;
transition: background 0.1s;
}
.db-col-resize-handle:hover,
.db-col-resize-handle:active {
background: var(--accent, #2383E2);
}
/* ── Column Header Context Menu ── */
.db-col-menu {
position: fixed;
min-width: 240px;
max-width: 320px;
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.14);
z-index: 800;
padding: 4px 0;
font-family: var(--font-sans);
font-size: 13px;
color: var(--text-primary);
}
.db-col-menu-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-weight: 600;
}
.db-col-menu-icon { display: flex; align-items: center; color: var(--text-dim); }
.db-col-menu-name { flex: 1; }
.db-col-menu-info { font-size: 11px; color: var(--text-dim); }
.db-col-menu-sep {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.db-col-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
transition: background 0.1s;
position: relative;
}
.db-col-menu-item:hover { background: var(--bg-hover); }
.db-col-menu-item.has-sub { justify-content: space-between; }
.db-col-menu-toggle { font-size: 11px; font-weight: 600; }
.db-col-menu-toggle.on { color: var(--accent, #2383E2); }
.db-col-menu-toggle.off { color: var(--text-dim); }
.db-col-menu-hint { font-size: 11px; color: var(--text-dim); margin-left: auto; margin-right: 4px; }
.db-col-menu-arrow { font-size: 14px; color: var(--text-dim); }
.db-col-menu-tag {
font-size: 10px;
background: var(--accent, #2383E2);
color: white;
padding: 1px 6px;
border-radius: 8px;
margin-left: auto;
}
.db-col-menu-sub {
border-left: 1px solid var(--border);
margin-left: 0;
}
.db-col-menu-sub .sub-item { padding-left: 28px; }
.db-col-menu-sub .sub-item.checked { font-weight: 600; }
.db-col-menu-subnote {
padding: 4px 12px 4px 28px;
font-size: 11px;
color: var(--text-dim);
line-height: 1.4;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.database-table-container { padding: 0 16px; }