Files
flowdeck/static/js/database_table.js
T
bruno f53205bc45
FlowDeck CI / test (push) Failing after 14m37s
FlowDeck CI / lint (push) Successful in 2m11s
FlowDeck CI / docker (push) Skipped
fix: peek des bases hydrate FlowDeckDB + Open de ligne, tableau pleine largeur (v7.50.1)
- page_editor_embed.html incluait seulement _page_editor_scripts.html :
  window.FlowDeckDB etait undefined dans l'iframe -> une base ouverte en side
  peek restait a « Loading database... ». Include _database_table_scripts.html
  ajoute (meme traitement que page_editor.html).
- Le panneau #db-side-peek n'existait que dans _database_table.html : hors page
  dediee, openPageInSidePeek etait un no-op. Desormais cree a la demande par
  ensureDbPeek (database_table.js) ; markup unique retire du partial.
- .database-table-container : plafond 1100px retire (regle heritee de Library)
  + max-width 100% pour .db-first et database-page-title : le tableau prend la
  largeur du document, plus de barre de scroll parasite sous le tableau.
- Tests: test_embed_editor_can_render_inline_databases. Suite 1265/0 (-n auto)
  + ruff 0. Bump VERSION/main.py 7.50.1, CHANGELOG, WORKLOAD, OpenAPI.
2026-10-06 15:15:01 -04:00

1380 lines
93 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
if (!window.__fdDbTableScriptsLoaded) {
window.__fdDbTableScriptsLoaded = true;
const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch{return null}})();
(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 'status':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 9 9"/></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>';
case 'person':
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="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
case 'files':
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="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
case 'relation': case 'rollup': case 'formula':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><line x1="8" y1="12" x2="16" y2="12"/></svg>';
case 'created_time': case 'last_edited_time':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
case 'created_by': case 'last_edited_by':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></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>';
}
}
var AUTO_TYPES = ['created_time','created_by','last_edited_time','last_edited_by'];
var READONLY_TYPES = AUTO_TYPES.concat(['rollup','formula']);
var PALETTE = {gray:'#8b8b8b',brown:'#9f6b53',orange:'#d9730d',yellow:'#cb912f',green:'#448361',blue:'#337ea9',purple:'#9065b0',pink:'#c14c8a',red:'#d44c47'};
function escHtml(s) { return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function parseJSON(s, d) { try { return typeof s === 'string' ? JSON.parse(s) : (s || d); } catch { return d; } }
function parseOpts(prop) {
var o = parseJSON(prop.options_json, []);
if (!Array.isArray(o)) o = [];
return o.map(function(x){ return typeof x === 'string' ? {name:x,color:'gray'} : {name:x.name||'',color:x.color||'gray'}; });
}
function optColor(prop, name) {
var found = parseOpts(prop).filter(function(o){ return o.name === name; })[0];
return (found && PALETTE[found.color]) || '#8b8b8b';
}
function toast(msg, kind) {
if (typeof window.showToast === 'function') window.showToast(msg, kind);
else if (kind === 'error') console.warn(msg);
}
function pvOf(cp) { return parseJSON(cp.property_values_json, {}) || {}; }
function pageVal(cp, prop) {
if (prop.prop_type === 'title') return cp.title || '';
var pv = pvOf(cp);
var v = pv[String(prop.id)];
if (v === undefined) v = pv[prop.name];
return v;
}
function setPageVal(cp, prop, val) {
var pv = pvOf(cp);
pv[String(prop.id)] = val;
cp.property_values_json = pv;
if (prop.prop_type === 'title') cp.title = val;
}
// ── v5.8.0: recurrence / reminder meta keys ──
function metaOf(cp, key) {
var m = pvOf(cp)[key];
return (m && typeof m === 'object' && !Array.isArray(m)) ? m : null;
}
function setMeta(cp, key, propId, val) {
var pv = pvOf(cp);
var m = pv[key];
if (!m || typeof m !== 'object' || Array.isArray(m)) m = {};
if (val === null || val === undefined) delete m[String(propId)];
else m[String(propId)] = val;
if (Object.keys(m).length) pv[key] = m; else delete pv[key];
cp.property_values_json = pv;
}
function recBadgeHtml(cp) {
var rec = metaOf(cp, '__recurrence__');
var rem = metaOf(cp, '__reminder__');
var h = '';
if (rec) { Object.keys(rec).forEach(function(k){ if (rec[k] && rec[k].freq) h += '<span class="db-ev-badge" title="Repeat: '+escHtml(rec[k].freq)+'">\u21bb</span>'; }); }
if (rem) { Object.keys(rem).forEach(function(k){ var r=rem[k]; if (r && r.unit && r.unit!=='none') h += '<span class="db-ev-badge" title="Reminder: '+escHtml(String(r.value)+' '+r.unit)+' before">🔔</span>'; }); }
return h;
}
// ── Value display (type-aware) ──
function initials(s) { s = (s||'?').trim(); return s ? s.charAt(0).toUpperCase() : '?'; }
function avatarHtml(p, size) {
size = size || 16;
var bg = p.avatar_color || '#5b6b8c';
if (p.avatar_url) return '<span class="db-person-avatar" style="width:'+size+'px;height:'+size+'px;background-image:url('+escHtml(p.avatar_url)+');background-size:cover"></span>';
return '<span class="db-person-avatar" style="width:'+size+'px;height:'+size+'px;background:'+escHtml(bg)+'">'+escHtml(initials(p.login||p.full_name))+'</span>';
}
function displayValue(prop, val) {
if (val === undefined || val === null || val === '') return '<span class="db-cell-value">\u2014</span>';
var t = prop.prop_type;
if (t === 'checkbox') return '<span class="db-cell-value">'+(val ? '✓' : '')+'</span>';
if (t === 'person' && Array.isArray(val)) {
if (!val.length) return '<span class="db-cell-value">\u2014</span>';
return val.map(function(p){ return '<span class="db-person-chip">'+avatarHtml(p)+escHtml(p.login||p.full_name||'')+'</span>'; }).join('');
}
if ((t === 'select' || t === 'status')) {
return '<span class="db-chip"><span class="db-chip-dot" style="background:'+optColor(prop,val)+'"></span>'+escHtml(val)+'</span>';
}
if (t === 'multi_select' && Array.isArray(val)) {
return val.map(function(v){ return '<span class="db-chip"><span class="db-chip-dot" style="background:'+optColor(prop,v)+'"></span>'+escHtml(v)+'</span>'; }).join('') || '<span class="db-cell-value">\u2014</span>';
}
if (t === 'date') {
return '<span class="db-cell-value">'+escHtml(String(val).slice(0,10))+'</span>';
}
if (t === 'created_time' || t === 'last_edited_time') {
return '<span class="db-cell-value db-cell-readonly">'+escHtml(String(val).slice(0,16).replace('T',' '))+'</span>';
}
if (t === 'created_by' || t === 'last_edited_by') {
return '<span class="db-person-chip">'+avatarHtml(val)+escHtml(val.login||val.full_name||'')+'</span>';
}
if (t === 'url' && val) return '<span class="db-cell-value"><a href="'+escHtml(val)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">'+escHtml(val)+'</a></span>';
if (t === 'email' && val) return '<span class="db-cell-value"><a href="mailto:'+escHtml(val)+'" onclick="event.stopPropagation()">'+escHtml(val)+'</a></span>';
if (t === 'relation' && Array.isArray(val)) return '<span class="db-cell-value">'+escHtml(val.join(', '))+'</span>';
return '<span class="db-cell-value">'+escHtml(Array.isArray(val)?val.join(', '):String(val))+'</span>';
}
function textValue(prop, val) {
if (val === undefined || val === null) return '';
if (Array.isArray(val)) return val.map(function(v){ return (v && (v.login||v.full_name)) || v; }).join(', ');
return String(val);
}
// ── DB Instance (one per container) ──
function DBInstance(containerEl, collectionId, initialData) {
var state = {
collection: (initialData && initialData.collection) || {},
properties: (initialData && initialData.properties) || [],
pages: (initialData && initialData.pages) || [],
views: (initialData && initialData.views) || [],
members: null,
activeViewId: null,
viewConfig: {},
viewType: 'table',
editingCell: null,
colWidths: {},
collapsedGroups: {},
calCursor: new Date(),
zones: null
};
function propsById() { var m = {}; state.properties.forEach(function(p){ m[String(p.id)] = p; }); return m; }
function findProp(ref) {
if (ref === undefined || ref === null || ref === '') return null;
return state.properties.filter(function(p){ return String(p.id) === String(ref) || p.name === ref; })[0] || null;
}
function firstPropOfTypes(types) {
return state.properties.filter(function(p){ return types.indexOf(p.prop_type) >= 0; })[0] || null;
}
function pageById(id) { return state.pages.filter(function(p){ return p.id === id; })[0]; }
function groupProp() {
return findProp(state.viewConfig.group_by) || firstPropOfTypes(['status','select']) || state.properties[0];
}
function activeView() {
return state.views.filter(function(v){ return v.id === state.activeViewId; })[0] || null;
}
function applyView(v) {
if (!v) { state.activeViewId = null; state.viewType = 'table'; state.viewConfig = {}; return; }
state.activeViewId = v.id;
state.viewType = v.view_type || 'table';
state.viewConfig = parseJSON(v.config_json, {}) || {};
}
function ensureView() {
if (state.views.length) { applyView(state.views[0]); return Promise.resolve(); }
return fetch('/db/'+collectionId+'/views/save-as', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body: JSON.stringify({name:'Table', view_type:'table', config:{}})
}).then(function(r){ return r.json(); }).then(function(d){
state.views = [d]; applyView(d);
}).catch(function(){ applyView(null); });
}
function persistConfig(patch, opts) {
opts = opts || {};
Object.keys(patch).forEach(function(k){ state.viewConfig[k] = patch[k]; });
var v = activeView();
if (!v) {
return fetch('/db/'+collectionId+'/views/save-as', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body: JSON.stringify({name: state.viewType.charAt(0).toUpperCase()+state.viewType.slice(1), view_type: state.viewType, config: state.viewConfig})
}).then(function(r){ return r.json(); }).then(function(d){ state.views.push(d); applyView(d); render(); });
}
if (opts.name) v.name = opts.name;
if (opts.viewType) { v.view_type = opts.viewType; state.viewType = opts.viewType; }
v.config_json = JSON.stringify(state.viewConfig);
if (!opts.silent) render();
return fetch('/db/views/'+v.id+'/config', {
method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body: JSON.stringify(Object.assign({}, patch, opts.name?{name:opts.name}:{}, opts.viewType?{view_type:opts.viewType}:{}))
}).catch(function(){});
}
// ── Root render ──
function render() {
state.properties.forEach(function(p){ if (!state.colWidths[p.id]) state.colWidths[p.id] = 200; });
var h = renderViewBar();
if (state.viewType === 'board') h += renderBoard();
else if (state.viewType === 'calendar') h += renderCalendar();
else if (state.viewType === 'gallery') h += renderGallery();
else if (state.viewType === 'list') h += renderList();
else h += renderTable();
containerEl.innerHTML = h;
bindEvents();
}
var VIEW_ICONS = {
table:'<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"/>',
board:'<rect x="3" y="3" width="5" height="18"/><rect x="10" y="3" width="5" height="12"/><rect x="17" y="3" width="4" height="16"/>',
calendar:'<rect x="3" y="4" width="18" height="18" rx="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"/>',
gallery:'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>',
list:'<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1"/><circle cx="3.5" cy="12" r="1"/><circle cx="3.5" cy="18" r="1"/>'
};
function viewSvg(t) {
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+(VIEW_ICONS[t]||VIEW_ICONS.table)+'</svg>';
}
function taskDbButton(collection) {
// Le libellé suit l'état réel de la base : convertie, à
// configurer, ou pas encore convertie. Un simple ✓ était
// introuvable pour l'utilisateur.
var isTask = !!(collection && collection.is_task);
var mapped = !!(collection && collection.task_assignee_prop &&
collection.task_status_prop && collection.task_due_prop);
var label = isTask ? (mapped ? '✓ Base de tâches' : '⚠ Configurer')
: 'Convertir en base de tâches';
var title = isTask
? (mapped ? 'Cette base alimente My Tasks'
: 'Base connectée mais mapping incomplet — reenregistrer')
: 'Relier Assigné à / Statut / Échéance pour alimenter My Tasks';
return '<button class="db-task-db-btn" data-task-db="' + collectionId +
'" title="' + escHtml(title) + '">' + label + '</button>';
}
function renderViewBar() {
var h = '<div class="db-view-bar" style="padding:4px 0 8px"><div class="db-view-tabs">';
if (!state.views.length) {
h += '<button class="db-view-tab active" data-view-id="">'+viewSvg(state.viewType)+' '+state.viewType.charAt(0).toUpperCase()+state.viewType.slice(1)+'</button>';
}
state.views.forEach(function(v){
var active = v.id === state.activeViewId;
h += '<button class="db-view-tab'+(active?' active':'')+'" data-view-id="'+v.id+'">'+viewSvg(v.view_type)+' '+escHtml(v.name)+
(active ? '<span class="db-view-settings" data-view-settings="1" title="View settings">⚙</span>' : '')+
'<span class="db-view-x" data-view-delete="'+v.id+'" title="Delete view">✕</span></button>';
});
h += '<button class="db-view-add" data-view-add="1" title="Add a view">+</button>';
h += '</div>';
h += '<div class="db-view-actions">' + taskDbButton(state.collection) +
'<button class="db-btn-new db-add-page-btn">+ New</button></div>';
h += '</div>';
return h;
}
function bindViewBar() {
containerEl.querySelectorAll('[data-view-id]').forEach(function(b){
b.onclick = function(e){
if (e.target.closest('[data-view-delete]') || e.target.closest('[data-view-settings]')) return;
var id = this.dataset.viewId; if (!id) return;
var v = state.views.filter(function(x){ return String(x.id) === String(id); })[0];
if (v) { applyView(v); render(); }
};
});
containerEl.querySelectorAll('[data-view-delete]').forEach(function(b){
b.onclick = function(e){
e.stopPropagation();
var id = parseInt(this.dataset.viewDelete);
if (!confirm('Delete this view?')) return;
fetch('/db/views/'+id+'/api', {method:'DELETE', headers:{'X-CSRF-Token':getCsrf()}}).then(function(){
state.views = state.views.filter(function(v){ return v.id !== id; });
if (state.activeViewId === id) applyView(state.views[0] || null);
render();
});
};
});
var settings = containerEl.querySelector('[data-view-settings]');
if (settings) settings.onclick = function(e){ e.stopPropagation(); openViewSettings(this); };
var add = containerEl.querySelector('[data-view-add]');
if (add) add.onclick = function(e){ e.stopPropagation(); openAddViewMenu(this); };
containerEl.querySelectorAll('.db-add-page-btn').forEach(function(b){ b.onclick = addNewPage; });
}
// ── Table view ──
function groupedProperties() {
var groups = [];
state.properties.forEach(function(p){
var g = p.group_name || '';
var last = groups[groups.length-1];
if (!last || last.name !== g) { last = {name:g, props:[]}; groups.push(last); }
last.props.push(p);
});
return groups;
}
function renderTable() {
var props = state.properties;
var pages = state.pages;
var groups = groupedProperties();
var hasGroups = groups.some(function(g){ return g.name; });
var h = '<div class="db-table-wrapper"><table class="db-table">';
h += '<thead>';
if (hasGroups) {
h += '<tr class="db-group-row">';
h += '<th style="width:32px;min-width:32px"></th>';
groups.forEach(function(g, gi){
var collapsed = state.collapsedGroups[gi];
h += '<th colspan="'+g.props.length+'" data-group-header="'+gi+'">';
if (g.name) {
h += '<span class="db-group-toggle'+(collapsed?' collapsed':'')+'" data-group-toggle="'+gi+'"><span class="chev">▾</span>'+escHtml(g.name)+'</span>';
}
h += '</th>';
});
h += '<th></th><th style="width:72px;min-width:72px"></th>';
h += '</tr>';
}
h += '<tr class="db-header-row">';
h += '<th class="db-th db-th-handle" style="width:32px;min-width:32px"></th>';
props.forEach(function(prop){
var w = state.colWidths[prop.id] || 200;
var groupIdx = 0;
groups.forEach(function(g, gi){ if (g.props.indexOf(prop) >= 0) groupIdx = gi; });
var collapsed = hasGroups && state.collapsedGroups[groupIdx];
h += '<th class="db-th db-th-property" data-colgroup="'+groupIdx+'" style="'+(collapsed?'display:none;':'')+'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><tbody>';
pages.forEach(function(cp){
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>';
props.forEach(function(prop){
var groupIdx = 0;
groups.forEach(function(g, gi){ if (g.props.indexOf(prop) >= 0) groupIdx = gi; });
var collapsed = hasGroups && state.collapsedGroups[groupIdx];
var val = pageVal(cp, prop);
var ro = READONLY_TYPES.indexOf(prop.prop_type) >= 0;
h += '<td class="db-td db-td-cell" data-colgroup="'+groupIdx+'" style="'+(collapsed?'display:none;':'')+'min-width:'+(state.colWidths[prop.id]||200)+'px">';
h += '<div class="db-cell-content'+(ro?' db-cell-ro':'')+'" data-page-id="'+cp.id+'" data-prop-id="'+prop.id+'">';
h += displayValue(prop, val);
if (prop.prop_type === 'date') h += recBadgeHtml(cp);
if (prop.prop_type === 'title') 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>';
});
var emptyNeeded = Math.max(0, 4 - pages.length);
for (var i=0;i<emptyNeeded;i++){
h += '<tr class="db-row db-row-empty"><td class="db-td db-td-handle"></td>';
props.forEach(function(){ 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>';
return h;
}
// ── Board view ──
function optionColumns(prop) {
if (!prop) return [{key:'', label:'Items'}];
if (prop.prop_type === 'person') {
var cols = (state.members||[]).map(function(m){ return {key:m.login, label:m.full_name||m.login, person:m}; });
cols.push({key:'__none__', label:'No assignee'});
return cols;
}
var opts = parseOpts(prop).map(function(o){ return {key:o.name, label:o.name, color:o.color}; });
if (!opts.length) {
var seen = {};
state.pages.forEach(function(cp){
var v = pageVal(cp, prop); if (v === undefined || v === null || v === '') return;
(Array.isArray(v)?v:[v]).forEach(function(x){ if (!seen[x]) { seen[x]=1; opts.push({key:x,label:x}); } });
});
opts.push({key:'__none__', label:'No value'});
} else {
opts.push({key:'__none__', label:'No value'});
}
return opts;
}
function columnKeyFor(prop, cp) {
var v = pageVal(cp, prop);
if (prop.prop_type === 'person') {
if (Array.isArray(v) && v.length) return v[0].login || v[0].full_name || '__none__';
return '__none__';
}
if (Array.isArray(v)) return v[0] || '__none__';
if (v === undefined || v === null || v === '') return '__none__';
return String(v);
}
function cardHtml(cp, opts) {
opts = opts || {};
var cover = opts.cover !== false ? coverHtml(cp) : '';
var size = state.viewConfig.card_size || 'small';
var cardProps = state.viewConfig.card_properties;
if (!Array.isArray(cardProps) || !cardProps.length) cardProps = defaultCardProps();
var body = '<div class="db-card-body">';
body += '<div class="db-card-title">'+(cp.icon&&cp.icon!=='file'?escHtml(cp.icon)+' ':'')+escHtml(cp.title||'Untitled')+'</div>';
cardProps.forEach(function(ref){
var p = findProp(ref); if (!p) return;
var v = pageVal(cp, p);
var txt = textValue(p, v);
if (!txt) return;
body += '<div class="db-card-prop"><span class="k">'+escHtml(p.name)+'</span><span>'+escHtml(txt.slice(0,60))+'</span></div>';
});
body += '</div>';
return '<div class="db-card '+(size==='small'?'compact':'')+'" draggable="true" data-card-page="'+cp.id+'">'+cover+body+'</div>';
}
function defaultCardProps() {
return state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3).map(function(p){ return p.id; });
}
function coverHtml(cp) {
if (cp.cover_url) return '<div class="db-card-cover" style="background-image:url('+escHtml(cp.cover_url)+')"></div>';
var mode = state.viewConfig.cover_mode || 'icon';
if (mode === 'none') return '';
if (mode === 'property') {
var p = findProp(state.viewConfig.cover_property);
if (p) {
var v = pageVal(cp, p);
var url = null;
if (Array.isArray(v) && v.length) url = (v[0] && (v[0].url||v[0].src)) || (typeof v[0]==='string'?v[0]:null);
else if (typeof v === 'string' && /^https?:\/\//.test(v)) url = v;
if (url) return '<div class="db-card-cover" style="background-image:url('+escHtml(url)+')"></div>';
}
}
if (mode === 'color') {
var g = groupProp();
var key = g ? columnKeyFor(g, cp) : '';
var col = g ? optColor(g, key) : '#8b8b8b';
return '<div class="db-card-cover" style="background:'+col+'"></div>';
}
return '<div class="db-card-cover">'+escHtml(cp.icon && cp.icon!=='file' ? cp.icon : '📄')+'</div>';
}
function renderBoard() {
var g = groupProp();
if (!g) return '<div style="padding:24px;color:var(--text-dim)">Add a Select, Status or Person property to group the board.</div>';
var sub = findProp(state.viewConfig.sub_group_by);
var cols = optionColumns(g);
var wip = state.viewConfig.wip_limits || {};
var h = '<div class="db-board-wrap">';
if (sub) {
var lanes = optionColumns(sub);
lanes.forEach(function(lane){
var lanePages = state.pages.filter(function(cp){ return columnKeyFor(sub, cp) === lane.key; });
if (!lanePages.length && lane.key === '__none__') return;
h += '<div class="db-lane"><div class="db-lane-head">'+escHtml(lane.label)+' <span class="db-board-count">'+lanePages.length+'</span></div>';
h += boardColumnsHtml(g, cols, lanePages, wip);
h += '</div>';
});
} else {
h += boardColumnsHtml(g, cols, state.pages, wip);
}
h += '</div>';
return h;
}
function boardColumnsHtml(g, cols, pages, wip) {
var h = '<div class="db-board">';
cols.forEach(function(col){
var colPages = pages.filter(function(cp){ return columnKeyFor(g, cp) === col.key; });
var limit = wip[col.key];
var exceeded = limit && colPages.length > Number(limit);
var dot = col.person ? avatarHtml(col.person) : (col.color ? '<span class="db-chip-dot" style="background:'+(PALETTE[col.color]||'#8b8b8b')+'"></span>' : '');
h += '<div class="db-board-col'+(exceeded?' wip-exceeded':'')+'" data-col-key="'+escHtml(col.key)+'">';
h += '<div class="db-board-col-head">'+dot+'<span>'+escHtml(col.label)+'</span><span class="db-board-count">'+colPages.length+'</span>';
if (limit) h += '<span class="db-wip">WIP '+colPages.length+'/'+limit+'</span>';
h += '</div>';
h += '<div class="db-board-cards" data-drop-col="'+escHtml(col.key)+'">';
colPages.forEach(function(cp){ h += cardHtml(cp); });
h += '<button class="db-card-add" data-add-col="'+escHtml(col.key)+'">+ New</button>';
h += '</div></div>';
});
h += '</div>';
return h;
}
// ── Calendar view (v5.8.0: day/week/month + server-expanded recurrences) ──
function dateProp() { return findProp(state.viewConfig.date_property) || firstPropOfTypes(['date']); }
var CAL_DOW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
var MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
function isoDay(d) { return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); }
function mondayOf(d) { var x = new Date(d); x.setDate(x.getDate() - ((x.getDay()+6)%7)); x.setHours(0,0,0,0); return x; }
function calMode() { var m = state.viewConfig.calendar_mode; return (m==='day'||m==='week') ? m : 'month'; }
function baseEvents() {
var dp = dateProp(); if (!dp) return {};
var byDate = {};
state.pages.forEach(function(cp){
var v = pageVal(cp, dp); if (!v) return;
var ds = String(v).slice(0,10);
(byDate[ds] = byDate[ds] || []).push({page_id:cp.id, title:cp.title, icon:cp.icon, recurring:false, time:String(v).length>10?String(v).slice(11,16):''});
});
return byDate;
}
function eventsForWindow(startIso, endIso) {
// Server expansion covers recurring occurrences; merge over base events.
var dp = dateProp();
if (!dp) return Promise.resolve({});
return fetch('/db/'+collectionId+'/calendar/api?start='+startIso+'&end='+endIso+'&date_property='+dp.id, {credentials:'same-origin'})
.then(function(r){ return r.ok ? r.json() : {events:[]}; })
.then(function(d){
var byDate = {};
(d.events||[]).forEach(function(ev){ (byDate[ev.date] = byDate[ev.date] || []).push(ev); });
return {byDate: byDate, timezone: d.timezone || ''};
})
.catch(function(){ return {byDate: baseEvents(), timezone:''}; });
}
function evChipHtml(ev) {
var badge = ev.recurring ? '<span class="db-ev-badge" title="Recurring">\u21bb</span>' : '';
return '<div class="db-cal-ev" draggable="true" data-card-page="'+ev.page_id+'" data-ev-date="'+escHtml(ev.date)+'">'
+ (ev.time ? '<span class="db-cal-ev-time">'+escHtml(ev.time)+'</span>' : '')
+ (ev.icon && ev.icon !== 'file' ? escHtml(ev.icon)+' ' : '')
+ escHtml(ev.title || 'Untitled') + badge + '</div>';
}
function renderCalendar() {
var dp = dateProp();
if (!dp) return '<div style="padding:24px;color:var(--text-dim)">Add a Date property to use the calendar view.</div>';
var mode = calMode();
var cur = state.calCursor;
var h = '<div class="db-cal-head">';
h += '<div class="db-cal-nav"><button data-cal-prev="1">\u2039</button><button data-cal-next="1">\u203a</button></div>';
var title;
if (mode === 'month') title = MONTH_NAMES[cur.getMonth()]+' '+cur.getFullYear();
else if (mode === 'week') { var ws=mondayOf(cur); title = MONTH_NAMES[ws.getMonth()].slice(0,3)+' '+ws.getDate()+' \u2013 '+(function(we){return MONTH_NAMES[we.getMonth()].slice(0,3)+' '+we.getDate();})(new Date(ws.getTime()+6*864e5)); }
else title = MONTH_NAMES[cur.getMonth()]+' '+cur.getDate()+', '+cur.getFullYear();
h += '<div class="db-cal-title">'+title+'</div>';
h += '<button class="db-btn-new" data-cal-today="1" style="padding:3px 10px">Today</button>';
h += '<div class="db-cal-modes">'+
['day','week','month'].map(function(m){ return '<button class="db-cal-mode'+(m===mode?' active':'')+'" data-cal-mode="'+m+'">'+m.charAt(0).toUpperCase()+m.slice(1)+'</button>'; }).join('')+
'</div>';
h += '</div>';
h += '<div id="db-cal-body" data-cal-loading="1"><div style="padding:24px;color:var(--text-dim)">Loading\u2026</div></div>';
return h;
}
function calWindow() {
var mode = calMode(); var cur = state.calCursor;
if (mode === 'month') {
var first = new Date(cur.getFullYear(), cur.getMonth(), 1);
var startDow = (first.getDay()+6)%7;
var start = new Date(first.getTime() - startDow*864e5);
var daysInMonth = new Date(cur.getFullYear(), cur.getMonth()+1, 0).getDate();
var totalCells = Math.ceil((startDow + daysInMonth)/7)*7;
return {start: start, days: totalCells, mode: mode, startDow: startDow, daysInMonth: daysInMonth};
}
if (mode === 'week') return {start: mondayOf(cur), days: 7, mode: mode};
return {start: new Date(cur.getFullYear(), cur.getMonth(), cur.getDate()), days: 1, mode: mode};
}
function paintCalendar(win, data) {
var body = containerEl.querySelector('#db-cal-body');
if (!body) return;
body.removeAttribute('data-cal-loading');
var today = new Date(); var todayIso = isoDay(today);
var byDate = data.byDate || {};
var h = '';
if (win.mode === 'month') {
h += '<div class="db-cal-grid">';
CAL_DOW.forEach(function(d){ h += '<div class="db-cal-dow">'+d+'</div>'; });
for (var i=0;i<win.days;i++) {
var cellDate = new Date(win.start.getTime() + i*864e5);
var iso = isoDay(cellDate);
var other = cellDate.getMonth() !== state.calCursor.getMonth();
var isToday = iso === todayIso;
h += '<div class="db-cal-day'+(other?' other':'')+(isToday?' today':'')+'" data-cal-date="'+iso+'">';
h += '<div class="db-cal-daynum">'+cellDate.getDate()+'</div>';
(byDate[iso]||[]).forEach(function(ev){ h += evChipHtml(ev); });
h += '</div>';
}
h += '</div>';
} else if (win.mode === 'week') {
h += '<div class="db-cal-grid db-cal-week">';
CAL_DOW.forEach(function(d){ h += '<div class="db-cal-dow">'+d+'</div>'; });
for (var j=0;j<7;j++) {
var d2 = new Date(win.start.getTime() + j*864e5);
var iso2 = isoDay(d2);
h += '<div class="db-cal-day'+(iso2===todayIso?' today':'')+'" data-cal-date="'+iso2+'" style="min-height:180px">';
h += '<div class="db-cal-daynum">'+d2.getDate()+'</div>';
(byDate[iso2]||[]).forEach(function(ev){ h += evChipHtml(ev); });
h += '</div>';
}
h += '</div>';
} else {
var iso3 = isoDay(win.start);
h += '<div class="db-cal-day-col" data-cal-date="'+iso3+'">';
h += '<div class="db-cal-dayhead">'+CAL_DOW[(win.start.getDay()+6)%7]+' '+MONTH_NAMES[win.start.getMonth()]+' '+win.start.getDate()+(iso3===todayIso?' \u00b7 today':'')+'</div>';
(byDate[iso3]||[]).slice().sort(function(a,b){ return (a.time||'99').localeCompare(b.time||'99'); }).forEach(function(ev){
h += '<div class="db-cal-agenda-row" draggable="true" data-card-page="'+ev.page_id+'" data-ev-date="'+iso3+'">'
+ '<span class="db-cal-ev-time">'+(ev.time || '\u2014')+'</span>'
+ '<span>'+(ev.icon && ev.icon!=='file' ? escHtml(ev.icon)+' ' : '')+escHtml(ev.title||'Untitled')+'</span>'
+ (ev.recurring ? '<span class="db-ev-badge">\u21bb</span>' : '')
+ '</div>';
});
if (!(byDate[iso3]||[]).length) h += '<div style="padding:16px;color:var(--text-dim);font-size:13px">No events</div>';
h += '</div>';
}
body.innerHTML = h;
bindCalendarCells();
}
var calSeq = 0;
function refreshCalendar() {
var win = calWindow();
var startIso = isoDay(win.start);
var endIso = isoDay(new Date(win.start.getTime() + (win.days-1)*864e5));
var seq = ++calSeq;
eventsForWindow(startIso, endIso).then(function(data){
if (seq !== calSeq) return; // a newer request won
paintCalendar(win, data);
});
}
function bindCalendarCells() {
// day cells: drop to reschedule
containerEl.querySelectorAll('[data-cal-date]').forEach(function(day){
day.addEventListener('dragover', function(e){ e.preventDefault(); this.classList.add('drop-target'); });
day.addEventListener('dragleave', function(){ this.classList.remove('drop-target'); });
day.addEventListener('drop', function(e){
e.preventDefault(); this.classList.remove('drop-target');
var pid = parseInt(e.dataTransfer.getData('text/plain'));
var cp = pageById(pid); var dp = dateProp();
if (!cp || !dp) return;
var old = pageVal(cp, dp);
var newDate = this.dataset.calDate;
var time = (old && String(old).length > 10) ? String(old).slice(10) : '';
setPageVal(cp, dp, newDate + time); render();
savePage(cp);
});
});
// event chips: open row in peek (stop drag click-through)
containerEl.querySelectorAll('.db-cal-ev, .db-cal-agenda-row').forEach(function(ev){
ev.addEventListener('dragstart', function(e){
e.dataTransfer.setData('text/plain', this.dataset.cardPage);
this.classList.add('dragging');
});
ev.addEventListener('dragend', function(){ this.classList.remove('dragging'); });
ev.addEventListener('click', function(e){
e.stopPropagation();
openPageInSidePeek(parseInt(this.dataset.cardPage));
});
ev.addEventListener('dblclick', function(e){
e.stopPropagation();
openEventEditor(parseInt(this.dataset.cardPage), this.dataset.evDate, e);
});
});
}
// ── v5.8.0: event editor popover (Repeat + Remind + time) ──
function openEventEditor(pageId, evDate, mouseEvt) {
var cp = pageById(pageId); var dp = dateProp();
if (!cp || !dp) return;
if (!state.zones) {
fetch('/db/timezones/api', {credentials:'same-origin'}).then(function(r){return r.ok?r.json():{zones:[]};})
.then(function(d){ state.zones = d.zones || []; }).catch(function(){});
}
var old = document.querySelector('.db-event-pop'); if (old) old.remove();
var pid = String(dp.id);
var rec = (metaOf(cp, '__recurrence__') || {})[pid] || null;
var rem = (metaOf(cp, '__reminder__') || {})[pid] || null;
var baseVal = String(pageVal(cp, dp) || '');
var curTime = baseVal.length >= 16 ? baseVal.slice(11,16) : '';
var pop = document.createElement('div');
pop.className = 'db-event-pop';
var freq = rec ? rec.freq : '';
var curTz = ((metaOf(cp, '__timezone__') || {})[pid]) || '';
var h = '<div class="pop-title">'+escHtml(cp.title||'Untitled')+'</div>';
h += '<div class="pop-row"><label>Time</label><input type="time" data-ev-time value="'+escHtml(curTime)+'"></div>';
h += '<div class="pop-row"><label>Timezone</label><select data-ev-tz><option value="">Default (yours/UTC)</option>';
(state.zones||[]).forEach(function(z){
h += '<option value="'+escHtml(z)+'"'+(curTz===z?' selected':'')+'>'+escHtml(z)+'</option>';
});
h += '</select></div>';
h += '<div class="pop-row"><label>Repeat</label><select data-ev-freq>';
[['','Does not repeat'],['daily','Daily'],['weekly','Weekly'],['monthly','Monthly']].forEach(function(o){
h += '<option value="'+o[0]+'"'+(freq===o[0]?' selected':'')+'>'+o[1]+'</option>';
});
h += '</select></div>';
if (freq) {
h += '<div class="pop-row"><label>Every</label><input type="number" min="1" max="365" data-ev-interval value="'+(rec.interval||1)+'" style="width:60px"></div>';
h += '<div class="pop-row"><label>Ends</label><select data-ev-endmode>';
h += '<option value="never"'+(rec.count==null&&!rec.until?' selected':'')+'>Never</option>';
h += '<option value="count"'+(rec.count!=null?' selected':'')+'>After…</option>';
h += '<option value="until"'+(rec.until?' selected':'')+'>On date…</option>';
h += '</select></div>';
if (rec.count != null) h += '<div class="pop-row"><label>occurrences</label><input type="number" min="1" max="1000" data-ev-count value="'+rec.count+'" style="width:70px"></div>';
if (rec.until) h += '<div class="pop-row"><label>until</label><input type="date" data-ev-until value="'+escHtml(String(rec.until).slice(0,10))+'"></div>';
}
h += '<div class="pop-sep"></div>';
h += '<div class="pop-row"><label>Remind</label><select data-ev-remunit>';
[['none','None'],['minutes','minutes before'],['hours','hours before'],['days','days before']].forEach(function(o){
var u = rem && rem.unit ? rem.unit : 'none';
h += '<option value="'+o[0]+'"'+(u===o[0]?' selected':'')+'>'+o[1]+'</option>';
});
h += '</select><input type="number" min="1" data-ev-remval value="'+(rem&&rem.value?rem.value:30)+'" style="width:60px;margin-left:6px"></div>';
h += '<div style="text-align:right;padding-top:8px"><button class="db-btn-new" data-ev-save style="padding:4px 14px">Save</button></div>';
pop.innerHTML = h;
document.body.appendChild(pop);
if (mouseEvt) {
pop.style.left = Math.min(mouseEvt.clientX, window.innerWidth-300)+'px';
pop.style.top = Math.min(mouseEvt.clientY, window.innerHeight-320)+'px';
} else { pop.style.left='50%'; pop.style.top='120px'; }
var freqSel = pop.querySelector('[data-ev-freq]');
function collectDraft() {
var draft = rec ? Object.assign({}, rec) : {};
draft.freq = freqSel.value;
var iv = pop.querySelector('[data-ev-interval]');
if (iv) draft.interval = parseInt(iv.value)||1;
if (!pop.querySelector('[data-ev-interval]')) draft.interval = draft.interval || 1;
var em = pop.querySelector('[data-ev-endmode]');
if (em) {
if (em.value === 'count') { var c=pop.querySelector('[data-ev-count]'); draft.count = c ? (parseInt(c.value)||null) : null; draft.until = null; }
else if (em.value === 'until') { var u=pop.querySelector('[data-ev-until]'); draft.until = (u&&u.value) ? u.value : null; draft.count = null; }
else { draft.count = null; draft.until = null; }
}
if (!draft.freq) return null;
return draft;
}
freqSel.onchange = function(){
// re-open to reveal/hide detail rows, keeping the in-place edits
setMeta(cp, '__recurrence__', dp.id, collectDraft());
rec = (metaOf(cp, '__recurrence__') || {})[pid] || null;
pop.remove();
openEventEditor(pageId, evDate, mouseEvt);
};
var endSel = pop.querySelector('[data-ev-endmode]');
if (endSel) endSel.onchange = function(){
setMeta(cp, '__recurrence__', dp.id, collectDraft());
rec = (metaOf(cp, '__recurrence__') || {})[pid] || null;
pop.remove();
openEventEditor(pageId, evDate, mouseEvt);
};
pop.querySelector('[data-ev-save]').onclick = function(){
var base = (baseVal.slice(0,10)) || evDate || isoDay(new Date());
var t = pop.querySelector('[data-ev-time]').value;
var recDraft = collectDraft();
var tzVal = pop.querySelector('[data-ev-tz]').value;
var unit = pop.querySelector('[data-ev-remunit]').value;
var val = parseInt(pop.querySelector('[data-ev-remval]').value)||0;
setPageVal(cp, dp, t && base ? (base+'T'+t) : base);
setMeta(cp, '__recurrence__', dp.id, recDraft);
setMeta(cp, '__timezone__', dp.id, tzVal || null);
setMeta(cp, '__reminder__', dp.id, unit==='none'?null:{value:val||1, unit:unit});
pop.remove(); render(); savePage(cp);
};
setTimeout(function(){
document.addEventListener('click', function onDoc(e){ if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
}, 0);
}
// ── Gallery view ──
function renderGallery() {
var size = state.viewConfig.card_size || 'medium';
var h = '<div class="db-gallery '+size+'">';
state.pages.forEach(function(cp){ h += cardHtml(cp); });
h += '<div class="db-card db-card-add-tile" data-gallery-add="1" style="display:flex;align-items:center;justify-content:center;min-height:120px;border:1px dashed var(--border);cursor:pointer;color:var(--text-dim)">+ New</div>';
h += '</div>';
return h;
}
// ── List view ──
function renderList() {
var cols = state.viewConfig.visible_properties;
var props;
if (Array.isArray(cols) && cols.length) props = cols.map(findProp).filter(Boolean);
else props = state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3);
var h = '<div class="db-list">';
state.pages.forEach(function(cp){
h += '<div class="db-list-row" data-card-page="'+cp.id+'">';
h += '<span class="db-list-icon">'+escHtml(cp.icon&&cp.icon!=='file'?cp.icon:'📄')+'</span>';
h += '<span class="db-list-title">'+escHtml(cp.title||'Untitled')+'</span>';
props.forEach(function(p){ h += '<span class="db-list-cell">'+escHtml(textValue(p, pageVal(cp,p)).slice(0,40))+'</span>'; });
h += '</div>';
});
h += '</div>';
return h;
}
// ── Event binding ──
function bindEvents() {
bindViewBar();
containerEl.querySelectorAll('.db-cell-content').forEach(function(el){
el.onclick = function(e){
if (state.editingCell || e.target.closest('.db-open-btn') || e.target.closest('a')) return;
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
};
});
containerEl.querySelectorAll('.db-open-page-btn').forEach(function(btn){
btn.onclick = function(e){ e.stopPropagation(); openPageInSidePeek(parseInt(this.dataset.pageId)); };
});
containerEl.querySelectorAll('.db-ai-fill-btn').forEach(function(btn){
btn.onclick = function(e){ e.stopPropagation(); aiFillRow(parseInt(this.dataset.pageId)); };
});
containerEl.querySelectorAll('.db-row').forEach(function(row){
row.onmouseenter = function(){ this.querySelectorAll('.db-hover-only').forEach(function(el){ el.style.visibility='visible'; }); };
row.onmouseleave = function(){ this.querySelectorAll('.db-hover-only').forEach(function(el){ el.style.visibility='hidden'; }); };
});
containerEl.querySelectorAll('.db-th-content').forEach(function(el){
el.onclick = function(e){ e.stopPropagation(); showColMenu(parseInt(this.dataset.propId), this.dataset.propType, this.dataset.propName, this); };
});
containerEl.querySelectorAll('.db-col-resize-handle').forEach(bindResize);
var addProp = containerEl.querySelector('.db-show-prop-modal-btn');
if (addProp) addProp.onclick = showPropModal;
// property group collapse
containerEl.querySelectorAll('[data-group-toggle]').forEach(function(el){
el.onclick = function(){
var gi = parseInt(this.dataset.groupToggle);
state.collapsedGroups[gi] = !state.collapsedGroups[gi];
render();
};
});
// open cards / rows
containerEl.querySelectorAll('[data-card-page]').forEach(function(el){
el.addEventListener('click', function(e){
if (e.target.closest('[data-add-col]')) return;
openPageInSidePeek(parseInt(this.dataset.cardPage));
});
el.addEventListener('dragstart', function(e){
e.dataTransfer.setData('text/plain', this.dataset.cardPage);
this.classList.add('dragging');
});
el.addEventListener('dragend', function(){ this.classList.remove('dragging'); });
});
// board drop targets
containerEl.querySelectorAll('[data-drop-col]').forEach(function(zone){
zone.addEventListener('dragover', function(e){ e.preventDefault(); this.classList.add('drop-target'); });
zone.addEventListener('dragleave', function(){ this.classList.remove('drop-target'); });
zone.addEventListener('drop', function(e){
e.preventDefault(); this.classList.remove('drop-target');
var pid = parseInt(e.dataTransfer.getData('text/plain'));
var cp = pageById(pid); var g = groupProp();
if (!cp || !g) return;
var key = this.dataset.dropCol;
var val = key === '__none__' ? '' : key;
if (g.prop_type === 'person') {
var m = (state.members||[]).filter(function(x){ return (x.login||x.full_name) === key; })[0];
val = m ? [m] : [];
} else if (g.prop_type === 'multi_select') {
val = key === '__none__' ? [] : [key];
}
setPageVal(cp, g, val); render();
savePage(cp);
});
});
// board add card
containerEl.querySelectorAll('[data-add-col]').forEach(function(b){
b.onclick = function(e){ e.stopPropagation(); addNewPage(this.dataset.addCol); };
});
// calendar nav (v5.8.0: mode-aware)
function stepCursor(dir) {
var c = state.calCursor;
if (calMode() === 'month') state.calCursor = new Date(c.getFullYear(), c.getMonth()+dir, 1);
else if (calMode() === 'week') { var w = new Date(c); w.setDate(w.getDate()+7*dir); state.calCursor = w; }
else { var d = new Date(c); d.setDate(d.getDate()+dir); state.calCursor = d; }
render();
}
var prev = containerEl.querySelector('[data-cal-prev]');
if (prev) prev.onclick = function(){ stepCursor(-1); };
var next = containerEl.querySelector('[data-cal-next]');
if (next) next.onclick = function(){ stepCursor(1); };
var today = containerEl.querySelector('[data-cal-today]');
if (today) today.onclick = function(){ state.calCursor = new Date(); render(); };
containerEl.querySelectorAll('[data-cal-mode]').forEach(function(b){
b.onclick = function(){
var m = this.dataset.calMode;
if (m !== calMode()) { persistConfig({calendar_mode: m}, {silent:true}); render(); }
};
});
if (state.viewType === 'calendar' && containerEl.querySelector('#db-cal-body')) refreshCalendar();
var galAdd = containerEl.querySelector('[data-gallery-add]');
if (galAdd) galAdd.onclick = function(){ addNewPage(); };
}
function bindResize(handle) {
handle.onpointerdown = function(e) {
e.preventDefault(); e.stopPropagation();
var propId = parseInt(this.dataset.propId);
var th = this.parentElement;
var thLeft = th.getBoundingClientRect().left;
function onMove(ev) {
var newW = Math.max(60, ev.clientX - thLeft);
state.colWidths[propId] = newW;
th.style.width = newW+'px'; th.style.minWidth = newW+'px';
var colIdx = state.properties.findIndex(function(p){ return p.id === propId; });
if (colIdx >= 0) containerEl.querySelectorAll('.db-row').forEach(function(row){
var 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);
};
}
// ── Cell editing ──
function startEditCell(pageId, propId, cellEl) {
var cp = pageById(pageId);
var prop = propsById()[String(propId)];
if (!cp || !prop || READONLY_TYPES.indexOf(prop.prop_type) >= 0) return;
var val = pageVal(cp, prop);
var t = prop.prop_type;
if (t === 'person') { openPersonPicker(cellEl, cp, prop); return; }
var input;
cellEl.innerHTML = ''; cellEl.classList.add('db-cell-editing');
if (t === 'checkbox') {
setPageVal(cp, prop, !val); render(); savePage(cp); return;
} else if (t === 'select' || t === 'status') {
input = document.createElement('select'); input.className = 'db-cell-input';
input.innerHTML = '<option value=""></option>' + parseOpts(prop).map(function(o){ return '<option'+(o.name===val?' selected':'')+'>'+escHtml(o.name)+'</option>'; }).join('');
} else if (t === 'date') {
input = document.createElement('input'); input.type = 'date'; input.className = 'db-cell-input';
input.value = val ? String(val).slice(0,10) : '';
} else if (t === 'number') {
input = document.createElement('input'); input.type = 'number'; input.className = 'db-cell-input'; input.value = val==null?'':val;
} else {
input = document.createElement('input'); input.type = 'text'; input.className = 'db-cell-input'; input.value = textValue(prop, val);
}
cellEl.appendChild(input); input.focus();
if (input.select) input.select();
state.editingCell = {pageId:pageId, propId:propId, input:input, cellEl:cellEl, prop:prop};
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;
var ed = state.editingCell; state.editingCell = null;
var cp = pageById(ed.pageId); var prop = ed.prop;
var val = ed.input.value;
if (prop.prop_type === 'multi_select') val = val ? val.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : [];
if (prop.prop_type === 'number' && val !== '') val = Number(val);
if (prop.prop_type === 'checkbox') val = !!val;
setPageVal(cp, prop, val);
render();
savePage(cp, ed.cellEl);
}
function cancelEditCell() { state.editingCell = null; render(); }
function openPersonPicker(cellEl, cp, prop) {
var old = document.querySelector('.db-person-picker'); if (old) old.remove();
var cur = pageVal(cp, prop); if (!Array.isArray(cur)) cur = [];
var menu = document.createElement('div');
menu.className = 'db-person-picker';
menu.innerHTML = (state.members||[]).map(function(m){
var checked = cur.some(function(x){ return String(x.id) === String(m.id); });
return '<div class="db-person-opt'+(checked?' checked':'')+'" data-mid="'+m.id+'">'+avatarHtml(m)+'<span>'+escHtml(m.full_name||m.login)+'</span>'+(checked?'<span style="margin-left:auto">✓</span>':'')+'</div>';
}).join('') || '<div class="db-person-opt">No members</div>';
document.body.appendChild(menu);
var r = cellEl.getBoundingClientRect();
menu.style.left = r.left+'px'; menu.style.top = (r.bottom+4)+'px';
menu.querySelectorAll('[data-mid]').forEach(function(opt){
opt.onclick = function(){
var m = (state.members||[]).filter(function(x){ return String(x.id) === String(this.dataset.mid); }.bind(this))[0];
var idx = cur.findIndex(function(x){ return String(x.id) === String(m.id); });
if (idx >= 0) cur.splice(idx,1); else cur.push(m);
setPageVal(cp, prop, cur.slice());
menu.remove(); render(); savePage(cp);
};
});
setTimeout(function(){
document.addEventListener('click', function onDoc(e){ if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
}, 0);
}
function savePage(cp, cellEl) {
var pv = pvOf(cp);
return fetch('/db/pages/'+cp.id+'/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)}; });
return r.json();
}).catch(function(err){
var msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save');
if (cellEl && err && err.status === 400) { cellEl.classList.add('db-cell-invalid'); cellEl.title = msg; }
toast('⚠ '+msg, 'error');
});
}
// ── v5.9.0: AI property suggestions ──
function aiFillRow(pageId) {
var cp = pageById(pageId); if (!cp) return;
var props = state.properties.filter(function(p){ return p.prop_type !== 'title' && READONLY_TYPES.indexOf(p.prop_type) < 0; });
if (!props.length) { toast('Aucune propriété à remplir'); return; }
toast('✨ FlowDeck AI : analyse en cours…');
fetch('/api/agent/writing/properties', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body: JSON.stringify({title: cp.title||'', content: cp.content||'', properties: props.map(function(p){ return {name:p.name, type:p.prop_type}; })})
}).then(function(r){ return r.json(); }).then(function(d){
if (!d || !d.ok) { toast((d&&d.error)||'Échec de la génération'); return; }
var filled = 0;
props.forEach(function(p){
var v = d.suggestions ? d.suggestions[p.name] : undefined;
if (v !== undefined && v !== null && v !== '') { setPageVal(cp, p, v); filled++; }
});
render(); savePage(cp);
toast(filled ? ('✨ '+filled+' propriété(s) remplie(s)') : 'Aucune suggestion disponible');
}).catch(function(){ toast('Erreur IA', 'error'); });
}
function addNewPage(colKey) {
var body = {title:''};
var g = groupProp();
if (colKey !== undefined && g && colKey !== '__none__') {
if (g.prop_type === 'person') {
var m = (state.members||[]).filter(function(x){ return (x.login||x.full_name) === colKey; })[0];
body.properties = {}; body.properties[String(g.id)] = m ? [m] : [];
} else if (g.prop_type === 'multi_select') {
body.properties = {}; body.properties[String(g.id)] = [colKey];
} else {
body.properties = {}; body.properties[String(g.id)] = colKey;
}
}
fetch('/api/collections/'+collectionId+'/pages', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify(body)})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) { state.pages.push({id:d.id,title:'',icon:'file',collection_id:collectionId,property_values_json:d.property_values_json||body.properties||{}}); render(); } })
.catch(function(e){ console.error('[DB] addNewPage error:', e); });
}
// ── Property modal ──
function showPropModal() {
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:440px;max-width:90vw;max-height:90vh;overflow:auto">'+
'<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="status">Status</option><option value="date">Date</option><option value="person">Person</option><option value="checkbox">Checkbox</option><option value="url">URL</option><option value="email">Email</option><option value="phone">Phone</option><option value="files">Files</option><option value="created_time">Created time</option><option value="created_by">Created by</option><option value="last_edited_time">Last edited time</option><option value="last_edited_by">Last edited by</option></select></div>'+
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Group (optional)</label><input class="db-prop-group-inline" type="text" placeholder="e.g. Basics" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></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 = function(){
var name = nameInput.value.trim(); if (!name) return;
var type = modal.querySelector('.db-prop-type-inline').value;
var group = modal.querySelector('.db-prop-group-inline').value.trim();
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);
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:name, prop_type:type, group_name:group, validation:validation})})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) { d.options_json = d.options_json || '[]'; d.group_name = group; state.properties.push(d); render(); modal.remove(); } })
.catch(function(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 ──
function showColMenu(propId, propType, propName, anchor) {
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 class="db-cm" data-cm="filter" style="padding:6px 12px;cursor:pointer">≡ Filter</div>'+
'<div class="db-cm" data-cm="sort" style="padding:6px 12px;cursor:pointer">↕ Sort</div>'+
'<div class="db-cm" data-cm="group" style="padding:6px 12px;cursor:pointer">▦ Group by this</div>'+
'<div class="db-cm" data-cm="groupname" style="padding:6px 12px;cursor:pointer">🗂 Move to group…</div>'+
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
'<div class="db-cm" data-cm="left" style="padding:6px 12px;cursor:pointer">├← Insert left</div>'+
'<div class="db-cm" data-cm="right" style="padding:6px 12px;cursor:pointer">→┤ Insert right</div>';
document.body.appendChild(menu);
menu.querySelectorAll('[data-cm]').forEach(function(item){
item.onclick = function(){
var act = this.dataset.cm; menu.remove();
if (act === 'group') { persistConfig({group_by: propId}); toast('Grouped by '+propName); }
else if (act === 'groupname') {
var g = prompt('Group name for "'+propName+'" (empty to ungroup):', (findProp(propId)||{}).group_name || '');
if (g !== null) {
fetch('/db/properties/'+propId+'/api', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({group_name:g.trim()})})
.then(function(){ var p = findProp(propId); if (p) p.group_name = g.trim(); render(); });
}
}
};
});
setTimeout(function(){
document.addEventListener('click', function onDoc(e){ if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
}, 0);
}
// ── View settings popover ──
function closePop() { var p = document.querySelector('.db-view-pop'); if (p) p.remove(); }
function openPop(anchor, html) {
closePop();
var pop = document.createElement('div');
pop.className = 'db-view-pop';
pop.innerHTML = html;
document.body.appendChild(pop);
var r = anchor.getBoundingClientRect();
pop.style.left = Math.min(r.left, window.innerWidth - 380) + 'px';
pop.style.top = (r.bottom + 6) + 'px';
setTimeout(function(){
document.addEventListener('click', function onDoc(e){ if (!pop.contains(e.target) && !anchor.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
}, 0);
return pop;
}
function propSelect(name, selected, filterFn) {
var opts = state.properties.filter(filterFn || function(){ return true; }).map(function(p){
return '<option value="'+p.id+'"'+(String(p.id)===String(selected)?' selected':'')+'>'+escHtml(p.name)+'</option>';
}).join('');
return '<select data-pop-field="'+name+'"><option value="">—</option>'+opts+'</select>';
}
function openViewSettings(anchor) {
var v = activeView();
var t = state.viewType;
var h = '<div class="pop-title">'+viewSvg(t)+' View settings</div>';
h += '<div class="pop-row"><label>Name</label><input type="text" data-pop-name value="'+escHtml(v?v.name:'')+'"></div>';
if (t === 'board') {
var g = groupProp();
h += '<div class="pop-row"><label>Group by</label>'+propSelect('group_by', state.viewConfig.group_by || (g?g.id:''), function(p){ return ['select','status','person','multi_select','text'].indexOf(p.prop_type)>=0; })+'</div>';
h += '<div class="pop-row"><label>Swimlanes (sub-group)</label>'+propSelect('sub_group_by', state.viewConfig.sub_group_by, function(p){ return ['select','status','person'].indexOf(p.prop_type)>=0; })+'</div>';
h += '<div class="pop-row"><label>Card size</label><select data-pop-field="card_size"><option value="small"'+(state.viewConfig.card_size==='small'?' selected':'')+'>Compact</option><option value="medium"'+((state.viewConfig.card_size||'medium')==='medium'?' selected':'')+'>Default</option></select></div>';
h += '<div class="pop-row"><label>Cover</label><select data-pop-field="cover_mode"><option value="none"'+(state.viewConfig.cover_mode==='none'?' selected':'')+'>None</option><option value="icon"'+((state.viewConfig.cover_mode||'icon')==='icon'?' selected':'')+'>Icon</option><option value="color"'+(state.viewConfig.cover_mode==='color'?' selected':'')+'>Color</option><option value="property"'+(state.viewConfig.cover_mode==='property'?' selected':'')+'>Property</option></select></div>';
if (state.viewConfig.cover_mode === 'property') h += '<div class="pop-row"><label>Cover property</label>'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'</div>';
h += '<div class="pop-sep"></div><div class="pop-hint">WIP limits (leave empty for none)</div>';
optionColumns(g).forEach(function(col){
if (col.key === '__none__') return;
var lim = (state.viewConfig.wip_limits||{})[col.key] || '';
h += '<div class="pop-row"><label>'+escHtml(col.label)+'</label><input type="number" min="0" data-wip="'+escHtml(col.key)+'" value="'+lim+'" style="width:70px"></div>';
});
h += '<div class="pop-sep"></div><div class="pop-hint">Card properties</div><div class="pop-chips">';
state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){
var on = (state.viewConfig.card_properties||defaultCardProps()).some(function(x){ return String(x)===String(p.id); });
h += '<span class="pop-chip'+(on?' on':'')+'" data-cardprop="'+p.id+'">'+escHtml(p.name)+'</span>';
});
h += '</div>';
} else if (t === 'calendar') {
h += '<div class="pop-row"><label>Date property</label>'+propSelect('date_property', state.viewConfig.date_property, function(p){ return p.prop_type==='date'; })+'</div>';
} else if (t === 'gallery') {
h += '<div class="pop-row"><label>Card size</label><select data-pop-field="card_size"><option value="small"'+(state.viewConfig.card_size==='small'?' selected':'')+'>Small</option><option value="medium"'+((state.viewConfig.card_size||'medium')==='medium'?' selected':'')+'>Medium</option><option value="large"'+(state.viewConfig.card_size==='large'?' selected':'')+'>Large</option></select></div>';
h += '<div class="pop-row"><label>Cover</label><select data-pop-field="cover_mode"><option value="none"'+(state.viewConfig.cover_mode==='none'?' selected':'')+'>None</option><option value="icon"'+((state.viewConfig.cover_mode||'icon')==='icon'?' selected':'')+'>Icon</option><option value="color"'+(state.viewConfig.cover_mode==='color'?' selected':'')+'>Color</option><option value="property"'+(state.viewConfig.cover_mode==='property'?' selected':'')+'>Property</option></select></div>';
if (state.viewConfig.cover_mode === 'property') h += '<div class="pop-row"><label>Cover property</label>'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'</div>';
h += '<div class="pop-sep"></div><div class="pop-hint">Card properties</div><div class="pop-chips">';
state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){
var on = (state.viewConfig.card_properties||defaultCardProps()).some(function(x){ return String(x)===String(p.id); });
h += '<span class="pop-chip'+(on?' on':'')+'" data-cardprop="'+p.id+'">'+escHtml(p.name)+'</span>';
});
h += '</div>';
} else if (t === 'table') {
h += '<div class="pop-hint">Assign properties to collapsible groups. Group headers appear above the table.</div>';
state.properties.forEach(function(p){
h += '<div class="pop-row"><label>'+escHtml(p.name)+'</label><input type="text" data-groupassign="'+p.id+'" value="'+escHtml(p.group_name||'')+'" style="width:130px"></div>';
});
} else {
h += '<div class="pop-hint">List uses the first properties. Configure card properties in a Board or Gallery view.</div>';
}
h += '<div class="pop-sep"></div>';
h += '<div class="pop-item" data-pop-action="duplicate">⧉ Duplicate view</div>';
h += '<div class="pop-item" data-pop-action="delete" style="color:#e5484d">🗑 Delete view</div>';
var pop = openPop(anchor, h);
// Wire name
var nameInput = pop.querySelector('[data-pop-name]');
if (nameInput) nameInput.onchange = function(){ if (v) { persistConfig({}, {name:this.value, silent:true}); render(); } };
// Wire selects / inputs
pop.querySelectorAll('[data-pop-field]').forEach(function(el){
el.onchange = function(){
var patch = {}; patch[this.dataset.popField] = this.value; persistConfig(patch);
};
});
pop.querySelectorAll('[data-wip]').forEach(function(el){
el.onchange = function(){
var limits = Object.assign({}, state.viewConfig.wip_limits || {});
if (this.value === '') delete limits[this.dataset.wip]; else limits[this.dataset.wip] = Number(this.value);
persistConfig({wip_limits: limits});
};
});
pop.querySelectorAll('[data-cardprop]').forEach(function(el){
el.onclick = function(){
var arr = (state.viewConfig.card_properties || defaultCardProps()).slice();
var id = Number(this.dataset.cardprop);
var idx = arr.findIndex(function(x){ return String(x)===String(id); });
if (idx >= 0) arr.splice(idx,1); else arr.push(id);
persistConfig({card_properties: arr});
};
});
pop.querySelectorAll('[data-groupassign]').forEach(function(el){
el.onchange = function(){
fetch('/db/properties/'+this.dataset.groupassign+'/api', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({group_name:this.value.trim()})})
.then(function(){ var p = findProp(this.dataset.groupassign); if (p) p.group_name = this.value.trim(); }.bind(this))
.then(function(){ render(); });
};
});
pop.querySelectorAll('[data-pop-action]').forEach(function(el){
el.onclick = function(){
var act = this.dataset.popAction; pop.remove();
if (act === 'duplicate' && v) {
fetch('/db/views/'+v.id+'/duplicate', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body:'{}'})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } });
} else if (act === 'delete' && v) {
if (!confirm('Delete this view?')) return;
fetch('/db/views/'+v.id+'/api', {method:'DELETE', headers:{'X-CSRF-Token':getCsrf()}}).then(function(){
state.views = state.views.filter(function(x){ return x.id !== v.id; });
applyView(state.views[0] || null); render();
});
}
};
});
}
function openAddViewMenu(anchor) {
var h = '<div class="pop-title">Add a view</div>';
['table','board','calendar','gallery','list'].forEach(function(t){
h += '<div class="pop-item" data-addview="'+t+'">'+viewSvg(t)+' '+t.charAt(0).toUpperCase()+t.slice(1)+'</div>';
});
var pop = openPop(anchor, h);
pop.querySelectorAll('[data-addview]').forEach(function(el){
el.onclick = function(){
var t = this.dataset.addview; pop.remove();
fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:t.charAt(0).toUpperCase()+t.slice(1), view_type:t, config:{}})})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } });
};
});
}
// ── Init ──
function loadMembers() {
if (state.members) return Promise.resolve(state.members);
return fetch('/db/'+collectionId+'/members/api').then(function(r){ return r.json(); }).then(function(d){ state.members = (d && d.members) || []; return state.members; }).catch(function(){ state.members = []; });
}
if (!initialData) {
fetch('/api/collections/'+collectionId+'/table-data').then(function(r){ return r.json(); }).then(function(d){
state.collection = d.collection || {}; state.properties = d.properties || []; state.pages = d.pages || []; state.views = d.views || [];
ensureView().then(loadMembers).then(render);
}).catch(function(e){ console.error('[DB] load error:', e); });
} else {
ensureView().then(loadMembers).then(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) ──
var PAGE_COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
if (PAGE_COLLECTION_ID > 0) {
var container = document.getElementById('db-table-container');
if (container) {
new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG);
}
}
// ── Side peek : panneau latéral des lignes de base (vanilla JS) ──
// Le panneau vit dans _database_table.html (page de base). Une base
// EMBARQUÉE dans un document n'a pas ce markup : on le crée à la demande
// (comme mt-peek de My Tasks), sinon l'icône Open de fin de ligne était
// un no-op silencieux. Il doit être câblé SUR le document, jamais via
// container.__x : le conteneur n'a pas de x-data.
var PEEK_HTML =
'<div class="peek-resize-handle" title="Glisser pour redimensionner — cliquer pour fermer"></div>' +
'<div class="db-side-peek-header">' +
'<button id="db-side-peek-close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>' +
'<div class="db-side-peek-title" id="db-side-peek-title"></div>' +
'<button id="db-side-peek-full" title="Open full page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>' +
'</div>' +
'<div class="db-side-peek-body">' +
'<div class="db-side-peek-loading" id="db-side-peek-loading">Chargement…</div>' +
'<iframe id="db-side-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>' +
'</div>';
function ensureDbPeek() {
var p = document.getElementById('db-side-peek');
if (p) return p;
p = document.createElement('div');
p.id = 'db-side-peek';
p.className = 'db-side-peek';
p.innerHTML = PEEK_HTML;
document.body.appendChild(p);
document.getElementById('db-side-peek-close').addEventListener('click', closeDbPeek);
document.getElementById('db-side-peek-full').addEventListener('click', function () {
var f = document.getElementById('db-side-peek-iframe');
// `dataset.page` et non `iframe.src` : le src porte `?embed=1`,
// la pleine page doit garder sa sidebar et sa barre.
if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page;
});
if (window.fdWirePeekResize) window.fdWirePeekResize(p, p.querySelector('.peek-resize-handle'), closeDbPeek);
return p;
}
function closeDbPeek() {
var p = document.getElementById('db-side-peek');
if (!p) return;
p.classList.remove('open');
document.getElementById('db-side-peek-iframe').src='about:blank';
var t = document.getElementById('db-side-peek-title');
if (t) t.textContent = '';
}
window.openPageInSidePeek = function(pageId) {
if (!pageId) return;
var peekEl = ensureDbPeek();
var peekFrame = document.getElementById('db-side-peek-iframe');
var peekTitle = document.getElementById('db-side-peek-title');
var peekLoading = document.getElementById('db-side-peek-loading');
peekEl.classList.add('open');
if (peekLoading) peekLoading.style.display = '';
if (peekFrame) peekFrame.src = 'about:blank';
if (peekTitle) peekTitle.textContent = '';
resolveRowContentPage(pageId).then(function(pid){
if (!peekEl.classList.contains('open')) return;
if (peekLoading) peekLoading.style.display = 'none';
// `?embed=1` : uniquement le document edite, pas l'habillage
// (sidebar + barre) — la pleine page reste via le bouton « full ».
if (peekFrame) { peekFrame.dataset.page = pid; peekFrame.src = '/pages/'+pid+'?embed=1'; }
})
.catch(function(){
// L'échec est rendu DANS le panneau (srcdoc) : sous le build
// CSP d'Alpine les toasts ne s'affichent pas partout.
if (peekFrame) {
peekFrame.srcdoc = '<p style="font-family:sans-serif;color:#888;padding:40px;text-align:center">Impossible d’ouvrir le contenu de cette ligne</p>';
}
peekEl.classList.add('open');
toast('Impossible d’ouvrir le contenu de cette ligne', 'error');
});
};
document.addEventListener('keydown', function(e) {
var p = document.getElementById('db-side-peek');
if (e.key === 'Escape' && p && p.classList.contains('open')) closeDbPeek();
});
document.addEventListener('pointerdown', function(e) {
var p = document.getElementById('db-side-peek');
if (!p || !p.classList.contains('open')) return;
if (p.contains(e.target)) return;
if (e.target.closest('#db-table-container, .database-table-container, [id^="inline-db-"]')) return;
closeDbPeek();
});
function resolveRowContentPage(rowId){
return fetch('/db/pages/'+rowId+'/open/api')
.then(function(r){
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(d){
if (!d || !d.page_id) throw new Error('page de contenu introuvable');
return d.page_id;
});
}
})();
}