- Service app/services/ai_writing.py: 6 actions sans outils (write, summarize, translate, continue, autocomplete, properties) + replis deterministes offline - Endpoints POST /api/agent/writing et /api/agent/writing/properties - Editeur: groupe slash AI (Write/Summarize/Translate/Continue) via E.aiSlash - Autocompletion inline AIAC (suggestion ~900ms, Tab accepte, Escape rejette) - Database: bouton AI fill (suggestions de proprietes Status/Priority/Resume) - Tests tests/test_ai_writing.py (+29) ; version 5.9.0 (VERSION, main.py) - CHANGELOG + ROADMAP v5.9.0 completes
473 lines
32 KiB
HTML
473 lines
32 KiB
HTML
<!-- ═══════════ Database Table Scripts — Vanilla JS ═══════════ -->
|
||
<style>.db-cell-invalid{outline:2px solid #e5484d;outline-offset:-1px;border-radius:4px}</style>
|
||
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
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:72px;min-width:72px"></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;white-space:nowrap"><button class="db-ai-btn db-ai-fill-btn" data-page-id="'+cp.id+'" title="Remplir les propriétés avec l\'IA" style="background:none;border:none;cursor:pointer;font-size:14px;padding:2px 4px;color:var(--text-dim)">✨</button><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));
|
||
};
|
||
});
|
||
// AI fill properties
|
||
containerEl.querySelectorAll('.db-ai-fill-btn').forEach(btn => {
|
||
btn.onclick = function(e) {
|
||
e.stopPropagation();
|
||
aiFillRow(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})})
|
||
.then(function(r){
|
||
if(!r.ok){
|
||
return r.json().then(function(d){ throw {status:r.status, detail:(d&&d.detail)||('Error '+r.status)}; });
|
||
}
|
||
cellEl.classList.remove('db-cell-invalid');
|
||
return r.json();
|
||
})
|
||
.catch(function(err){
|
||
// Validation failed (400) → show message + revert the cell.
|
||
const msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save');
|
||
if(err && err.status===400){
|
||
cellEl.classList.add('db-cell-invalid');
|
||
cellEl.title = msg;
|
||
}
|
||
if(typeof window.showToast==='function') window.showToast('⚠ '+msg,'error');
|
||
else alert('⚠ '+msg);
|
||
// Revert to the stored value.
|
||
const stored = pv[String(propId)];
|
||
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(stored||'\u2014')+'</span>';
|
||
if (prop.prop_type === 'title') {
|
||
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); };
|
||
});
|
||
}
|
||
|
||
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); };
|
||
}
|
||
|
||
// ── v5.9.0: AI property suggestions ──
|
||
async function aiFillRow(pageId) {
|
||
const cp = state.pages.find(p => p.id === pageId);
|
||
if (!cp) return;
|
||
const props = state.properties.filter(p => p.prop_type !== 'title');
|
||
if (!props.length) {
|
||
if (typeof window.showToast === 'function') window.showToast('Aucune propriété à remplir');
|
||
return;
|
||
}
|
||
const payload = {
|
||
title: cp.title || '',
|
||
content: cp.content || '',
|
||
properties: props.map(p => ({ name: p.name, type: p.prop_type }))
|
||
};
|
||
if (typeof window.showToast === 'function') window.showToast('✨ FlowDeck AI : analyse en cours…');
|
||
try {
|
||
const r = await fetch('/api/agent/writing/properties', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const d = await r.json();
|
||
if (!d || !d.ok) {
|
||
if (typeof window.showToast === 'function') window.showToast((d && d.error) || 'Échec de la génération');
|
||
return;
|
||
}
|
||
let pv;
|
||
try { pv = typeof cp.property_values_json === 'string' ? JSON.parse(cp.property_values_json) : (cp.property_values_json || {}); }
|
||
catch (e) { pv = {}; }
|
||
let filled = 0;
|
||
for (const p of props) {
|
||
const v = d.suggestions ? d.suggestions[p.name] : undefined;
|
||
if (v !== undefined && v !== null && v !== '') { pv[String(p.id)] = v; filled++; }
|
||
}
|
||
cp.property_values_json = pv;
|
||
await fetch('/db/pages/' + pageId + '/api', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||
body: JSON.stringify({ title: cp.title, properties: pv })
|
||
});
|
||
render();
|
||
if (typeof window.showToast === 'function') window.showToast(filled ? ('✨ ' + filled + ' propriété(s) remplie(s)') : 'Aucune suggestion disponible');
|
||
} catch (e) {
|
||
if (typeof window.showToast === 'function') window.showToast('Erreur IA');
|
||
}
|
||
}
|
||
|
||
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 (inline, self-contained) ──
|
||
function showPropModal() {
|
||
// Remove any existing inline modal
|
||
var old = containerEl.querySelector('.db-prop-modal-inline');
|
||
if (old) old.remove();
|
||
|
||
var modal = document.createElement('div');
|
||
modal.className = 'db-prop-modal-inline';
|
||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:1000;display:flex;align-items:center;justify-content:center';
|
||
modal.innerHTML = '<div class="db-modal" style="background:var(--bg-modal);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,0.15);width:420px;max-width:90vw;overflow:hidden">'+
|
||
'<div class="db-modal-header"><h3>Add a property</h3><button class="db-prop-modal-close-btn" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;color:var(--text-dim);font-size:18px">×</button></div>'+
|
||
'<div class="db-modal-body" style="padding:16px">'+
|
||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Name</label><input class="db-prop-name-inline" type="text" placeholder="Property name" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></div>'+
|
||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Type</label><select class="db-prop-type-inline" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"><option value="text">Text</option><option value="number">Number</option><option value="select">Select</option><option value="multi_select">Multi-select</option><option value="date">Date</option><option value="checkbox">Checkbox</option><option value="url">URL</option><option value="email">Email</option><option value="phone">Phone</option></select></div>'+
|
||
'<div style="display:flex;gap:16px;margin-bottom:12px">'+
|
||
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-required-inline" type="checkbox"> Required</label>'+
|
||
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-unique-inline" type="checkbox"> Unique</label>'+
|
||
'</div>'+
|
||
'<div style="display:flex;gap:10px;margin-bottom:12px">'+
|
||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Min</label><input class="db-prop-min-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
|
||
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Max</label><input class="db-prop-max-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
|
||
'</div>'+
|
||
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px"><button class="db-prop-cancel-inline" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-secondary);font-size:13px;cursor:pointer">Cancel</button><button class="db-prop-create-inline" style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent,#2383E2);color:white;font-size:13px;cursor:pointer">Create</button></div>'+
|
||
'</div></div>';
|
||
document.body.appendChild(modal);
|
||
|
||
var nameInput = modal.querySelector('.db-prop-name-inline');
|
||
nameInput.focus();
|
||
|
||
modal.querySelector('.db-prop-modal-close-btn').onclick = function(){ modal.remove(); };
|
||
modal.querySelector('.db-prop-cancel-inline').onclick = function(){ modal.remove(); };
|
||
modal.onclick = function(e){ if(e.target===modal) modal.remove(); };
|
||
|
||
modal.querySelector('.db-prop-create-inline').onclick = async function(){
|
||
var name = nameInput.value.trim();
|
||
if(!name) return;
|
||
var type = modal.querySelector('.db-prop-type-inline').value;
|
||
var validation = {};
|
||
if(modal.querySelector('.db-prop-required-inline').checked) validation.required = true;
|
||
if(modal.querySelector('.db-prop-unique-inline').checked) validation.unique = true;
|
||
var min = modal.querySelector('.db-prop-min-inline').value;
|
||
var max = modal.querySelector('.db-prop-max-inline').value;
|
||
if(min!=='') validation.min = Number(min);
|
||
if(max!=='') validation.max = Number(max);
|
||
try {
|
||
var r = await fetch('/db/'+collectionId+'/properties/api',{
|
||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||
body:JSON.stringify({name:name,prop_type:type,validation:validation})
|
||
});
|
||
var d = await r.json();
|
||
if(d.id){ state.properties.push(d); render(); modal.remove(); }
|
||
} catch(e){ console.error('[DB] createProperty error:',e); }
|
||
};
|
||
nameInput.onkeydown = function(e){ if(e.key==='Enter') modal.querySelector('.db-prop-create-inline').click(); };
|
||
}
|
||
|
||
// ── Column context menu (inline) ──
|
||
function showColMenu(propId, propType, propName, anchor) {
|
||
// Remove any existing
|
||
var old = document.querySelector('.db-col-menu-inline');
|
||
if (old) old.remove();
|
||
|
||
var menu = document.createElement('div');
|
||
menu.className = 'db-col-menu-inline';
|
||
menu.style.cssText = '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-size:13px;color:var(--text-primary)';
|
||
var rect = anchor.getBoundingClientRect();
|
||
menu.style.top = (rect.bottom+4)+'px'; menu.style.left = rect.left+'px';
|
||
|
||
menu.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;font-weight:600">'+
|
||
'<span>'+propTypeSvg(propType)+'</span><span>'+escHtml(propName)+'</span><span style="font-size:11px;color:var(--text-dim)">ⓘ</span></div>'+
|
||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||
'<div style="padding:6px 12px;cursor:pointer">≡ Filter</div>'+
|
||
'<div style="padding:6px 12px;cursor:pointer">↕ Sort</div>'+
|
||
'<div style="padding:6px 12px;cursor:pointer">▦ Group</div>'+
|
||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||
'<div style="padding:6px 12px;cursor:pointer">├← Insert left</div>'+
|
||
'<div style="padding:6px 12px;cursor:pointer">→┤ Insert right</div>';
|
||
|
||
document.body.appendChild(menu);
|
||
setTimeout(() => document.addEventListener('click', function onDoc(e) {
|
||
if(!menu.contains(e.target)) { menu.remove(); 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 (auto-creates panel) ──
|
||
function openPageInSidePeek(pageId) {
|
||
var panel = document.getElementById('db-side-peek');
|
||
if (!panel) {
|
||
// Create panel dynamically
|
||
panel = document.createElement('div');
|
||
panel.id = 'db-side-peek';
|
||
panel.style.cssText = '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';
|
||
panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+
|
||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+
|
||
'<button id="db-side-peek-close" style="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)">×</button>'+
|
||
'<button id="db-side-peek-full" style="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)" title="Open full page">↗</button>'+
|
||
'</div>'+
|
||
'<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>';
|
||
document.body.appendChild(panel);
|
||
|
||
document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; };
|
||
document.getElementById('db-side-peek-full').onclick = function(){
|
||
var ifr = document.getElementById('db-side-peek-iframe');
|
||
if(ifr&&ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank');
|
||
};
|
||
// Resize
|
||
var rh = document.getElementById('db-side-peek-resize');
|
||
rh.onpointerdown = function(e) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
var sx = e.clientX, sw = panel.offsetWidth;
|
||
function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; }
|
||
function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; }
|
||
document.body.style.cursor='col-resize';
|
||
document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up);
|
||
};
|
||
}
|
||
var iframe = document.getElementById('db-side-peek-iframe');
|
||
if (iframe) { 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 = ''; }
|
||
}
|
||
})();
|
||
</script>
|