Files
flowdeck/static/js/database_table.js
bruno fc8548194a
FlowDeck CI / lint (push) Failing after 1m32s
FlowDeck CI / test (push) Failing after 27m50s
FlowDeck CI / docker (push) Skipped
feat(templates): refonte complète des templates façon Notion + vues/agents-skills
Templates (v7.71.x) :

- registre unifié \	emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler)

- sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document

- 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée

- récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates

- correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title

- tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8)

Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
2026-10-10 18:52:19 -04:00

2610 lines
192 KiB
JavaScript
Raw Permalink 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>';
case 'place':
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="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></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 === 'place') {
var pname = '', paddr = '';
if (typeof val === 'string') { paddr = val; }
else if (val && typeof val === 'object') { pname = val.name || ''; paddr = val.address || ''; }
var plabel = pname || paddr || '—';
var pin = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
var sub = (pname && paddr && pname !== paddr) ? '<span style="color:var(--text-dim);font-size:11px"> · '+escHtml(String(paddr).slice(0,40))+'</span>' : '';
return '<span class="db-cell-value" style="display:inline-flex;align-items:center;gap:4px">'+pin+'<span>'+escHtml(String(plabel).slice(0,48))+'</span>'+sub+'</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 (prop && prop.prop_type === 'place') {
if (typeof val === 'string') return val;
if (val && typeof val === 'object') return [val.name, val.address].filter(Boolean).join(' — ');
return '';
}
if (Array.isArray(val)) return val.map(function(v){ return (v && (v.login||v.full_name)) || v; }).join(', ');
return String(val);
}
// ── Registre de vues Notion (doc §7) : chaque vue = module enregistré ──
window.FDViews = window.FDViews || { _defs: {}, register: function(t, d){ this._defs[t] = d; }, get: function(t){ return this._defs[t]; } };
window.FDViews.register('table', {label:'Table', editable:true, requires:[]});
window.FDViews.register('board', {label:'Board', editable:true, requires:[]});
window.FDViews.register('calendar', {label:'Calendar', editable:true, requires:[{type:'date'}]});
window.FDViews.register('gallery', {label:'Gallery', editable:true, requires:[]});
window.FDViews.register('list', {label:'List', editable:false, requires:[]});
window.FDViews.register('chart', {label:'Chart', editable:false, requires:[]});
window.FDViews.register('timeline', {label:'Timeline', editable:true, requires:[{type:'date', hint:'Add a Date property to use the timeline view.'}]});
window.FDViews.register('feed', {label:'Feed', editable:false, requires:[]});
window.FDViews.register('map', {label:'Map', editable:false, requires:[{type:'place', autoCreate:true}]});
window.FDViews.register('dashboard', {label:'Dashboard', editable:true, requires:[]});
window.FDViews.register('form', {label:'Form', editable:true, requires:[], headerActions:['preview','share']});
var FD_VIEW_ORDER = ['table','board','calendar','gallery','list','chart','timeline','feed','map','dashboard','form'];
function fdViewLabel(t){ var d = window.FDViews.get(t); return d ? d.label : (t.charAt(0).toUpperCase()+t.slice(1)); }
// ── 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 if (state.viewType === 'chart') h += renderChart();
else if (state.viewType === 'timeline') h += renderTimeline();
else if (state.viewType === 'feed') h += renderFeed();
else if (state.viewType === 'map') h += renderMap();
else if (state.viewType === 'dashboard') h += renderDashboard();
else if (state.viewType === 'form') h += renderForm();
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"/>',
chart:'<path d="M3 3v18h18"/><rect x="7" y="12" width="3" height="6"/><rect x="12" y="8" width="3" height="10"/><rect x="17" y="5" width="3" height="13"/>',
timeline:'<line x1="3" y1="6" x2="21" y2="6"/><rect x="7" y="10" width="10" height="4" rx="1"/><rect x="4" y="16" width="14" height="4" rx="1"/>',
feed:'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="14" y2="13"/>',
map:'<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
dashboard:'<rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="5" rx="1"/><rect x="13" y="10" width="8" height="11" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/>',
form:'<rect x="5" y="3" width="14" height="18" rx="2"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="13" y2="16"/>'
};
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>';
var actions = taskDbButton(state.collection);
if (state.viewType === 'form') {
actions += '<button class="db-btn-new" data-form-preview="1" title="Preview form">👁 Preview</button>' +
'<button class="db-btn-new" data-form-share="1" title="Share form" style="background:var(--accent,#2383E2);color:#fff">Share form</button>';
} else {
actions += '<button class="db-btn-new db-add-page-btn">+ New</button>';
}
h += '<div class="db-view-actions">' + actions + '</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;
}
// ── Vues Notion : helpers partagés ──
function numOf(v) { var n = Number(v); return isFinite(n) ? n : null; }
function localAggregate(spec) {
spec = spec || {};
var gprop = findProp(spec.group_by) || null;
var sprop = spec.sub_group_by ? findProp(spec.sub_group_by) : null;
var kind = (spec.measure && spec.measure.kind) || 'count';
var mprop = (spec.measure && spec.measure.property) ? findProp(spec.measure.property) : null;
var hidden = {}; (spec.hidden_groups || []).forEach(function(k){ hidden[k] = 1; });
function mvals(members) {
var out = [];
members.forEach(function(cp){
if (!mprop) { out.push(1); return; }
var n = numOf(pageVal(cp, mprop));
if (n !== null) out.push(n);
});
return out;
}
function reduce(vals, count) {
if (kind === 'count') return count;
if (!vals.length) return 0;
if (kind === 'sum') return vals.reduce(function(a,b){ return a+b; }, 0);
if (kind === 'average' || kind === 'avg') return vals.reduce(function(a,b){ return a+b; }, 0) / vals.length;
if (kind === 'median') { var s = vals.slice().sort(function(a,b){ return a-b; }); var m = Math.floor(s.length/2); return s.length % 2 ? s[m] : (s[m-1]+s[m])/2; }
if (kind === 'min') return Math.min.apply(null, vals);
if (kind === 'max') return Math.max.apply(null, vals);
return count;
}
var buckets = {};
state.pages.forEach(function(cp){
var key;
if (!gprop) key = 'All';
else if (gprop.prop_type === 'multi_select') {
var arr = pageVal(cp, gprop);
(Array.isArray(arr) && arr.length ? arr : ['(empty)']).forEach(function(k){
k = String(k || '(empty)');
(buckets[k] = buckets[k] || []).push(cp);
});
return;
} else {
var t = textValue(gprop, pageVal(cp, gprop));
key = t ? String(t) : '(empty)';
}
(buckets[key] = buckets[key] || []).push(cp);
});
if (gprop && !spec.omit_zero && (gprop.prop_type === 'select' || gprop.prop_type === 'status')) {
parseOpts(gprop).forEach(function(o){ if (!buckets[o.name]) buckets[o.name] = []; });
}
var orderBy = (spec.order && spec.order.by) || spec.group_order || 'group_order';
var schemaOrder = gprop ? parseOpts(gprop).map(function(o){ return o.name; }) : [];
var keys = Object.keys(buckets);
keys.sort(function(a, b){
if (orderBy === 'value') return reduce(mvals(buckets[b]), buckets[b].length) - reduce(mvals(buckets[a]), buckets[a].length);
if (orderBy === 'label') return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
var ia = schemaOrder.indexOf(a), ib = schemaOrder.indexOf(b);
if (ia >= 0 || ib >= 0) return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
});
if (String((spec.order && spec.order.direction) || '').toLowerCase() === 'desc') keys = keys.reverse();
var truncated = keys.length > 200;
keys = keys.slice(0, 200);
var groups = [], running = 0;
keys.forEach(function(k){
if (hidden[k]) return;
if (state.chartHidden && state.chartHidden[k]) return;
var members = buckets[k];
var v = reduce(mvals(members), members.length);
if (spec.cumulative && (kind === 'count' || kind === 'sum')) { running += v; v = running; }
var sub = [];
if (sprop) {
var sb = {};
members.forEach(function(cp){
var sk = textValue(sprop, pageVal(cp, sprop)) || '(empty)';
(sb[sk] = sb[sk] || []).push(cp);
});
Object.keys(sb).slice(0, 50).forEach(function(sk){
sub.push({key: sk, label: sk, value: reduce(mvals(sb[sk]), sb[sk].length)});
});
sub.sort(function(a,b){ return b.value - a.value; });
}
groups.push({key: k, label: k, color: gprop ? optColor(gprop, k) : '#8b8b8b', value: v, count: members.length,
row_ids: members.slice(0, 500).map(function(p){ return p.id; }), sub: sub});
});
var total = reduce(mvals(state.pages), state.pages.length);
var grand = groups.reduce(function(a,g){ return a + g.value; }, 0) || 1;
groups.forEach(function(g){ g.percent = g.value / grand; });
return {groups: groups, total: total, truncated: truncated, scanned: state.pages.length};
}
function fmtNum(v) {
if (v === null || v === undefined || isNaN(v)) return '—';
var a = Math.abs(v);
if (a >= 1e9) return (v/1e9).toFixed(2)+'B';
if (a >= 1e6) return (v/1e6).toFixed(2)+'M';
if (a >= 1e3) return (v/1e3).toFixed(1)+'K';
if (v === Math.round(v)) return String(Math.round(v));
return String(Math.round(v*100)/100);
}
function fmtPct(p) { return (Math.round(p*1000)/10)+'%'; }
// ── Vue Map (Leaflet, cadrage auto, plafond 100, Map by) ──
function placeProp() { return findProp(state.viewConfig.place_property) || firstPropOfTypes(['place']); }
function placeCoords(val) {
if (!val) return null;
if (typeof val === 'string') {
var m = val.match(/(-?\d+(?:\.\d+)?)\s*[,;]\s*(-?\d+(?:\.\d+)?)/);
if (m) { var la = Number(m[1]), ln = Number(m[2]);
if (la >= -90 && la <= 90 && ln >= -180 && ln <= 180) return {lat: la, lng: ln, label: val}; }
return null;
}
if (typeof val === 'object' && val.lat !== null && val.lat !== undefined && val.lat !== '' && val.lng !== null && val.lng !== undefined && val.lng !== '') {
var la2 = Number(val.lat), ln2 = Number(val.lng);
if (isFinite(la2) && isFinite(ln2)) return {lat: la2, lng: ln2, label: val.name || val.address || ''};
}
return null;
}
function renderMap() {
var pp = placeProp();
if (!pp) return '<div style="padding:24px;color:var(--text-dim)">This view needs a Place property. <button data-map-add-place="1">+ Create Place property</button></div>';
var pts = [], nores = 0;
state.pages.forEach(function(cp){
var v = pageVal(cp, pp);
if (v === undefined || v === null || v === '') { nores++; return; }
var c = placeCoords(v);
if (c) pts.push({cp: cp, lat: c.lat, lng: c.lng, label: c.label});
else nores++;
});
var over = pts.length > 100;
var shown = pts.slice(0, 100);
var h = '<div style="padding:6px 2px 18px">';
h += '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;font-size:12px;color:var(--text-secondary)">';
h += '<label>Map by <select data-map-by>' + state.properties.filter(function(p){ return p.prop_type==='place'; }).map(function(p){ return '<option value="'+p.id+'"'+(pp.id===p.id?' selected':'')+'>'+escHtml(p.name)+'</option>'; }).join('') + '</select></label>';
h += '<span>'+pts.length+' places · '+shown.length+' shown'+(nores ? ' · '+nores+' without coordinates' : '')+'</span>';
h += '<button data-map-recenter="1" title="Recenter" style="margin-left:auto">◎ Recenter</button></div>';
if (over) h += '<div style="background:rgba(217,115,13,.12);border:1px solid rgba(217,115,13,.4);border-radius:8px;padding:8px 12px;font-size:12px;margin-bottom:8px">'+pts.length+' places — only the first 100 are shown. Refine filters.</div>';
h += '<div data-map-el style="height:480px;border:1px solid var(--border);border-radius:10px;overflow:hidden;position:relative;background:var(--bg-secondary)">';
h += '<div data-map-fallback style="padding:18px;font-size:13px;display:flex;flex-direction:column;gap:6px;max-height:480px;overflow:auto">' +
shown.map(function(p){ return '<div>📍 <a href="#" data-card-page="'+p.cp.id+'" style="font-weight:600">'+escHtml(p.cp.title||'Untitled')+'</a> <span style="color:var(--text-dim)">'+escHtml(p.label||'')+' · '+p.lat.toFixed(3)+', '+p.lng.toFixed(3)+'</span></div>'; }).join('') + '</div></div>';
h += '<div data-map-data type="application/json" style="display:none">'+escHtml(JSON.stringify(shown.map(function(p){ return {id: p.cp.id, title: p.cp.title||'Untitled', lat: p.lat, lng: p.lng, label: p.label||''}; }))).replace(/&quot;/g,'"')+'</div>';
h += '</div>';
return h;
}
function ensureLeaflet(cb) {
if (window.L && window.L.map) { cb(); return; }
function loadScript(src, done) { var s = document.createElement('script'); s.src = src; s.onload = done; s.onerror = done; document.head.appendChild(s); }
function loadCss(href) { if (!document.querySelector('link[data-leaflet]')) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.setAttribute('data-leaflet','1'); document.head.appendChild(l); } }
loadCss('/static/js/vendor/leaflet.css');
loadScript('/static/js/vendor/leaflet.js', function(){ cb(); });
}
function bindMap() {
var el = containerEl.querySelector('[data-map-el]');
if (!el) return;
var raw = containerEl.querySelector('[data-map-data]');
var pts = [];
try { pts = JSON.parse(raw ? raw.textContent : '[]'); } catch (e) { pts = []; }
ensureLeaflet(function(){
if (!(window.L && window.L.map) || !el.isConnected) return;
try {
if (el._leaflet_id) return;
var map = window.L.map(el, {scrollWheelZoom: true});
window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19, attribution: '© OpenStreetMap'}).addTo(map);
var bounds = [];
var grouped = {};
pts.forEach(function(p){ var k = p.lat.toFixed(3)+','+p.lng.toFixed(3); (grouped[k] = grouped[k] || []).push(p); });
Object.keys(grouped).forEach(function(k){
var g = grouped[k];
var mk = g.length > 1
? window.L.marker([g[0].lat, g[0].lng]).bindPopup('<b>'+g.length+' places</b><br>'+g.slice(0,5).map(function(p){ return escHtml(p.title); }).join('<br>'))
: window.L.marker([g[0].lat, g[0].lng]).bindPopup('<b>'+escHtml(g[0].title)+'</b><br><span>'+escHtml(g[0].label||'')+'</span><br><a href="#" data-map-open="'+g[0].id+'">Open →</a>');
mk.addTo(map);
bounds.push([g[0].lat, g[0].lng]);
});
// Cadrage auto sur l'étendue des épingles (amélioration vs capture 07).
if (bounds.length === 1) map.setView(bounds[0], 12);
else if (bounds.length) map.fitBounds(bounds, {padding: [30, 30]});
else map.setView([46.5, -73.5], 5);
el.querySelectorAll('[data-map-open]').forEach(function(){});
map.on('popupopen', function(ev){
var a = ev.popup.getElement() && ev.popup.getElement().querySelector('[data-map-open]');
if (a) a.onclick = function(e){ e.preventDefault(); openPageInSidePeek(parseInt(a.getAttribute('data-map-open'))); };
});
el._leaflet_map = map;
var fb = el.querySelector('[data-map-fallback]');
if (fb && bounds.length) fb.style.display = 'none';
} catch (e) { /* fallback list stays visible */ }
});
}
// ── Vue Dashboard (widgets multi-sources, modes Lecture/Édition, filtres globaux) ──
function renderDashboard() {
var mode = state.dashMode || 'view';
var widgets = state.dashWidgets || [];
var gf = state.viewConfig.global_filters || [];
var h = '<div style="padding:6px 2px 22px">';
h += '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px">';
h += '<div style="display:flex;border:1px solid var(--border);border-radius:7px;overflow:hidden">' +
'<button data-dash-mode="view" style="font-size:12px;padding:4px 12px;border:none;cursor:pointer;'+(mode==='view'?'background:var(--bg-active);font-weight:600':'background:transparent')+'">Lecture</button>' +
'<button data-dash-mode="edit" style="font-size:12px;padding:4px 12px;border:none;cursor:pointer;'+(mode==='edit'?'background:var(--bg-active);font-weight:600':'background:transparent')+'">Édition</button></div>';
h += '<span style="font-size:12px;color:var(--text-dim)">'+widgets.length+' / 12 widgets</span>';
if (mode === 'edit') h += '<button data-dash-save="1" style="font-size:12px;margin-left:auto">💾 Save layout</button>';
h += '</div>';
if (gf.length) h += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;align-items:center"><span style="font-size:12px;color:var(--text-dim)">Global filters:</span>' +
gf.map(function(f, i){ return '<span class="db-chip">'+escHtml(f.property+' '+f.operator+' '+JSON.stringify(f.value))+' <a href="#" data-dash-gf-del="'+i+'" style="color:inherit">✕</a></span>'; }).join('') +
(mode === 'edit' ? '<button data-dash-gf-add="1" style="font-size:12px">+ Filter</button>' : '') + '</div>';
else if (mode === 'edit') h += '<div style="margin-bottom:10px"><button data-dash-gf-add="1" style="font-size:12px">+ Global filter</button></div>';
if (!widgets.length) {
h += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;max-width:760px">';
h += '<button data-dash-tpl="agent" style="text-align:left;border:1px solid var(--border);border-radius:10px;padding:16px;background:var(--bg-secondary);cursor:pointer"><b>✨ Create with the agent</b><br><span style="font-size:12px;color:var(--text-dim)">First-draft layout from this base schema.</span></button>';
h += '<button data-dash-add="1" style="text-align:left;border:1px solid var(--border);border-radius:10px;padding:16px;background:var(--bg-secondary);cursor:pointer"><b>+ Add a widget</b><br><span style="font-size:12px;color:var(--text-dim)">Chart, table, timeline… from this or another base.</span></button>';
h += '<button data-dash-tpl="status" style="text-align:left;border:1px solid var(--border);border-radius:10px;padding:16px;background:var(--bg-secondary);cursor:pointer"><b>📊 Start from a template</b><br><span style="font-size:12px;color:var(--text-dim)">Status chart + priority table + milestones.</span></button>';
h += '</div>';
h += '</div>';
return h;
}
// Grille : lignes de 4 widgets max.
var rows = [];
widgets.forEach(function(w, i){ var r = Math.floor(i/4); (rows[r] = rows[r] || []).push({w: w, i: i}); });
rows.forEach(function(row){
h += '<div style="display:grid;grid-template-columns:repeat('+row.length+',1fr);gap:12px;margin-bottom:12px">';
row.forEach(function(cell){
var w = cell.w;
var title = escHtml(w.title || ('Widget '+(cell.i+1)));
h += '<section style="border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);overflow:hidden;min-height:120px">';
h += '<header style="display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--border);font-size:13px"><span style="cursor:grab" title="Drag">≡</span><b style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+title+'</b>';
if (mode === 'edit') h += '<button data-dash-dup="'+w._lid+'" title="Duplicate" style="font-size:12px">⧉</button><button data-dash-del="'+w._lid+'" title="Remove" style="font-size:12px">✕</button>';
else h += '<button data-dash-open="'+w._lid+'" title="Open source view" style="font-size:12px">↗</button>';
h += '</header><div style="padding:10px" data-dash-body="'+w._lid+'"><span style="font-size:12px;color:var(--text-dim)">Loading…</span></div></section>';
});
h += '</div>';
});
if (mode === 'edit' && widgets.length < 12) h += '<div style="text-align:center"><button data-dash-add="1" style="font-size:13px">+ Add a widget</button></div>';
h += '</div>';
return h;
}
function dashFetchWidgets() {
var v = activeView();
if (!v) { state.dashWidgets = []; return Promise.resolve(); }
return fetch('/db/'+collectionId+'/views/'+v.id+'/widgets', {credentials:'same-origin'})
.then(function(r){ return r.ok ? r.json() : {widgets: []}; })
.then(function(d){
var ws = (d.widgets || []).map(function(w, i){ w._lid = w.id || ('l'+i); return w; });
if (!ws.length && state.viewConfig.widgets_json) {
try { var arr = typeof state.viewConfig.widgets_json === 'string' ? JSON.parse(state.viewConfig.widgets_json) : state.viewConfig.widgets_json;
ws = (arr || []).map(function(w, i){ w._lid = w.id || ('l'+i); return w; }); } catch (e) {}
}
state.dashWidgets = ws;
}).catch(function(){ state.dashWidgets = []; });
}
function dashWidgetData(w) {
var srcCid = w.source_collection_id || collectionId;
var inline = w.inline_config || {};
try { if (typeof w.inline_config_json === 'string') inline = JSON.parse(w.inline_config_json); } catch (e) {}
var gf = state.viewConfig.global_filters || [];
// Filtres globaux : par nom de propriété, ignorés si absents de la source.
function applyGlobal(rows, props) {
var names = {}; props.forEach(function(p){ names[p.name] = p; });
var extra = gf.filter(function(f){ return names[f.property]; });
if (!extra.length) return rows;
return rows.filter(function(cp){
return extra.every(function(f){
var p = names[f.property];
var t = textValue(p, pageVal(cp, p)).toLowerCase();
var ev = Array.isArray(f.value) ? f.value.join(' ').toLowerCase() : String(f.value == null ? '' : f.value).toLowerCase();
if ((f.operator||'is') === 'contains') return t.indexOf(ev) >= 0;
return t === ev;
});
});
}
if (w.source_view_id && !inline.view_type) {
return fetch('/db/'+srcCid+'/views/'+w.source_view_id+'/rows?limit=50', {credentials:'same-origin'})
.then(function(r){ return r.json(); }).then(function(d){ return {kind:'rows', rows:(d.rows||[])}; })
.catch(function(){ return {kind:'rows', rows:[]}; });
}
return fetch('/api/collections/'+srcCid+'/table-data', {credentials:'same-origin'})
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(d){
if (!d) return {kind:'rows', rows:[]};
var props = d.properties || [], pages = d.pages || [];
var rows = applyGlobal(pages, props);
var vt = inline.view_type || 'table';
if (vt === 'chart' || inline.group_by) {
var gprop = props.filter(function(p){ return p.name === inline.group_by; })[0];
var bk = {};
rows.forEach(function(cp){
var pv = {}; try { pv = JSON.parse(cp.property_values_json || '{}'); } catch (e) {}
var vv = gprop ? (pv[String(gprop.id)] != null ? pv[String(gprop.id)] : pv[gprop.name]) : cp.title;
var k = (vv === undefined || vv === null || vv === '') ? '(empty)' : String(Array.isArray(vv) ? (vv[0]||'(empty)') : vv);
(bk[k] = bk[k] || []).push(cp);
});
var groups = Object.keys(bk).map(function(k){ return {label: k, value: bk[k].length, count: bk[k].length, color: '#2383E2'}; });
return {kind:'agg', groups: groups, total: rows.length};
}
return {kind:'rows', rows: rows.slice(0, 50), total: rows.length, props: props};
}).catch(function(){ return {kind:'rows', rows:[]}; });
}
function bindDashboard() {
(state.dashWidgets || []).forEach(function(w){
var slot = containerEl.querySelector('[data-dash-body="'+w._lid+'"]');
if (!slot) return;
dashWidgetData(w).then(function(d){
if (!slot.isConnected) return;
if (d.kind === 'agg') {
var max = Math.max.apply(null, d.groups.map(function(g){ return g.value; }).concat([1]));
slot.innerHTML = '<div style="font-size:26px;font-weight:700">'+escHtml(fmtNum(d.total))+'</div>' +
d.groups.slice(0,6).map(function(g){
return '<div style="display:flex;align-items:center;gap:6px;font-size:12px;margin-top:4px"><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+escHtml(g.label)+'</span><div style="flex:2;background:var(--bg-tertiary);border-radius:3px;height:8px"><div style="height:8px;border-radius:3px;background:'+g.color+';width:'+Math.max(2,(g.value/max*100)).toFixed(0)+'%"></div></div><b>'+g.value+'</b></div>';
}).join('');
} else {
var rows = d.rows || [];
slot.innerHTML = rows.slice(0,8).map(function(cp){
return '<div style="font-size:12px;padding:3px 0;border-bottom:1px solid var(--border);cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" data-card-page="'+cp.id+'">'+escHtml(cp.title||'Untitled')+'</div>';
}).join('') || '<span style="font-size:12px;color:var(--text-dim)">No rows</span>';
if ((d.total||rows.length) > 8) slot.innerHTML += '<div style="font-size:11px;color:var(--text-dim);margin-top:4px">'+d.total+' rows — <a href="#" data-dash-open="'+w._lid+'">open view</a></div>';
slot.querySelectorAll('[data-card-page]').forEach(function(el){ el.onclick = function(){ openPageInSidePeek(parseInt(el.getAttribute('data-card-page'))); }; });
}
});
});
}
// ── Vue Form + Form builder (questions ↔ propriétés, partage, preview) ──
function renderForm() {
var c = state.viewConfig || {};
var qs = state.formQs || [];
var sharing = c.sharing || {audience:'workspace'};
var scopeTxt = sharing.audience === 'web' ? 'Anyone on the web with the link can fill out this form.' : sharing.audience === 'closed' ? 'This form is closed — nobody can fill it out.' : 'Only members of this workspace can fill out this form.';
var h = '<div style="max-width:640px;margin:0 auto;padding:10px 12px 30px">';
h += '<div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:4px"><button data-form-preview="1" style="font-size:12px">👁 Preview</button><button data-form-share="1" style="font-size:12px;background:var(--accent,#2383E2);color:#fff;border:none;border-radius:6px;padding:4px 12px;cursor:pointer">Share form</button></div>';
h += '<input data-form-title value="'+escHtml(c.title || state.collection.name || 'Form title')+'" placeholder="Form title" style="width:100%;font-size:26px;font-weight:700;border:none;background:transparent;outline:none;margin:6px 0">';
h += '<input data-form-desc value="'+escHtml(c.description||'')+'" placeholder="Description (optional)" style="width:100%;font-size:13px;border:none;background:transparent;outline:none;color:var(--text-secondary);margin-bottom:10px">';
h += '<div style="border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:12px;margin-bottom:12px;background:var(--bg-secondary)">🔒 '+escHtml(scopeTxt)+' <a href="#" data-form-share="1">Change</a></div>';
h += '<div data-form-qs style="display:flex;flex-direction:column;gap:10px">';
qs.forEach(function(q, i){
h += '<div style="border:1px solid var(--border);border-radius:10px;padding:10px 12px;background:var(--bg-primary)" data-fq="'+i+'">';
h += '<div style="display:flex;align-items:center;gap:8px"><input data-fq-label value="'+escHtml(q.label_override || q.property_name)+'" style="flex:1;font-size:14px;font-weight:600;border:none;background:transparent;outline:none" title="Rename'+(q.sync_label===false?' (unsynced)':' (renames the property)')+'">';
h += '<button data-fq-up="'+i+'" title="Move up" style="font-size:12px">↑</button><button data-fq-down="'+i+'" title="Move down" style="font-size:12px">↓</button><button data-fq-menu="'+i+'" title="Settings" style="font-size:12px">•••</button></div>';
h += '<div style="margin-top:6px;border:1px dashed var(--border);border-radius:6px;padding:8px 10px;color:var(--text-dim);font-size:13px">Respondent\'s answer'+(q.required?' <span style="color:#e5484d">*</span>':'')+'</div>';
if (q.help_text) h += '<div style="font-size:12px;color:var(--text-dim);margin-top:4px">'+escHtml(q.help_text)+'</div>';
if (q.sync_label === false) h += '<div style="font-size:11px;color:var(--text-dim)">Label unsynced from property “'+escHtml(q.property_name)+'”</div>';
h += '</div>';
});
h += '</div>';
h += '<button data-form-addq="1" style="margin-top:12px;font-size:13px">+ Add a question</button>';
h += '<div style="border-top:1px solid var(--border);margin-top:18px;padding-top:12px"><b style="font-size:13px">Confirmation screen</b>';
var sub = c.submit_screen || {};
h += '<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap"><input data-form-btn-text value="'+escHtml(sub.button_text||'Envoyer')+'" title="Button text" style="font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:6px"><input data-form-confirm-title value="'+escHtml(sub.confirmation_title||'Merci !')+'" title="Confirmation title" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:6px"></div>';
h += '<input data-form-confirm-body value="'+escHtml(sub.confirmation_body||'')+'" title="Confirmation message" placeholder="Confirmation message" style="width:100%;font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:6px;margin-top:8px"></div>';
h += '</div>';
return h;
}
function formFetchQs() {
var v = activeView();
if (!v) return Promise.resolve();
return fetch('/db/'+collectionId+'/views/'+v.id+'/form', {credentials:'same-origin'})
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(d){
if (!d) return;
if (d.config && (d.config.title || d.config.sharing || d.config.submit_screen)) {
Object.keys(d.config).forEach(function(k){ if (['title','description','sharing','submit_screen','notify_email'].indexOf(k) >= 0) state.viewConfig[k] = d.config[k]; });
}
state.formQs = (d.questions || []).map(function(q){
return {property_name: q.property_name, position: q.position, label_override: q.label_override,
sync_label: q.sync_label !== 0, help_text: q.help_text || '', required: !!q.required,
widget: q.widget || 'auto', max_selections: q.max_selections || null,
conditional: q.conditional_json ? JSON.parse(q.conditional_json) : (q.conditional || null)};
});
}).catch(function(){});
}
function formSave(silent) {
var v = activeView();
if (!v) return Promise.resolve();
// Synchro Notion : renommer une question synchronisée renomme la propriété.
(state.formQs || []).forEach(function(q){
if (q.sync_label !== false && q.label_override && q.label_override !== q.property_name) {
var p = state.properties.filter(function(x){ return x.name === q.property_name; })[0];
if (p) { p.name = q.label_override; q.property_name = q.label_override; q.label_override = null; }
}
});
return fetch('/db/'+collectionId+'/views/'+v.id+'/form', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body: JSON.stringify({questions: state.formQs, config: {title: state.viewConfig.title, description: state.viewConfig.description, sharing: state.viewConfig.sharing, submit_screen: state.viewConfig.submit_screen}})})
.then(function(){ if (!silent) toast('Form saved'); });
}
// ── Éditeur de cellule place (autocomplétion + position + manuel) ──
function openPlaceEditor(cellEl, cp, prop) {
var old = document.querySelector('.db-place-pop'); if (old) old.remove();
var cur = pageVal(cp, prop);
var curTxt = cur ? (typeof cur === 'string' ? cur : [cur.name, cur.address].filter(Boolean).join(' — ')) : '';
var pop = document.createElement('div');
pop.className = 'db-place-pop db-view-pop';
pop.innerHTML = '<div class="pop-title">📍 Place</div>' +
'<div class="pop-row"><input type="text" data-pl-q value="'+escHtml(curTxt)+'" placeholder="Name or address…" style="width:100%;max-width:300px"></div>' +
'<div data-pl-list></div>' +
'<div class="pop-row"><button data-pl-geo style="font-size:12px">◉ Use my location</button>' +
'<button data-pl-manual style="font-size:12px">Manual lat,lng</button></div>' +
'<div class="pop-row"><button data-pl-clear style="font-size:12px">Clear</button><button data-pl-save class="db-btn-new" style="padding:4px 14px;font-size:12px">Save</button></div>';
document.body.appendChild(pop);
var r = cellEl.getBoundingClientRect();
pop.style.left = Math.min(r.left, window.innerWidth-380)+'px';
pop.style.top = (r.bottom+6)+'px';
var q = pop.querySelector('[data-pl-q]'), list = pop.querySelector('[data-pl-list]'), chosen = null, timer = null;
q.focus();
q.oninput = function(){
chosen = null;
clearTimeout(timer);
timer = setTimeout(function(){
var v = q.value.trim();
if (v.length < 3) { list.innerHTML = ''; return; }
fetch('/db/geocode/search?q='+encodeURIComponent(v), {credentials:'same-origin'})
.then(function(r2){ return r2.ok ? r2.json() : {suggestions:[]}; })
.then(function(d){
list.innerHTML = (d.suggestions||[]).map(function(s, i){
return '<div class="pop-item" data-pl-pick="'+i+'">📍 <span><b>'+escHtml(s.name||'')+'</b><br><span style="font-size:11px;color:var(--text-dim)">'+escHtml((s.address||'').slice(0,80))+'</span></span></div>';
}).join('') || '<div class="pop-hint">No suggestion — free text will be kept as an unresolved place.</div>';
list._sugs = d.suggestions || [];
list.querySelectorAll('[data-pl-pick]').forEach(function(el){
el.onclick = function(){ chosen = list._sugs[parseInt(el.getAttribute('data-pl-pick'))]; q.value = (chosen.name ? chosen.name+' — ' : '')+(chosen.address||''); };
});
}).catch(function(){});
}, 300);
};
pop.querySelector('[data-pl-geo]').onclick = function(){
if (!navigator.geolocation) { toast('Geolocation unavailable'); return; }
navigator.geolocation.getCurrentPosition(function(pos){
chosen = {name: 'Current position', address: '', lat: pos.coords.latitude, lng: pos.coords.longitude, provider: 'browser', manual: true};
q.value = 'Current position ('+pos.coords.latitude.toFixed(4)+', '+pos.coords.longitude.toFixed(4)+')';
}, function(){ toast('Location refused', 'error'); });
};
pop.querySelector('[data-pl-manual]').onclick = function(){
var v = prompt('Coordinates "lat,lng":', '');
if (!v) return;
var m = v.match(/(-?\d+(?:\.\d+)?)\s*[,;]\s*(-?\d+(?:\.\d+)?)/);
if (!m) { toast('Invalid coordinates', 'error'); return; }
chosen = {name: q.value.trim() || 'Manual pin', address: '', lat: Number(m[1]), lng: Number(m[2]), provider: 'manual', manual: true};
};
pop.querySelector('[data-pl-clear]').onclick = function(){ setPageVal(cp, prop, null); pop.remove(); render(); savePage(cp); };
pop.querySelector('[data-pl-save]').onclick = function(){
var val = chosen;
if (!val) {
var t = q.value.trim();
if (!t) { pop.remove(); return; }
val = {name: t, address: t, lat: null, lng: null, provider: 'none', manual: false};
}
val.resolved_at = new Date().toISOString();
setPageVal(cp, prop, val); 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);
}
// ── Vue Chart (donut / barres / lignes / KPI, drilldown, export PNG) ──
function chartSpec() {
var c = state.viewConfig || {};
var spec = {group_by: c.group_by || c.chart_property || '', sub_group_by: c.sub_group_by || null,
measure: c.measure || {kind: (c.aggregate || 'count')}, cumulative: !!c.cumulative,
omit_zero: c.omit_zero !== undefined ? !!c.omit_zero : true,
hidden_groups: c.hidden_groups || [], order: c.order || c.group_order || 'group_order'};
if (typeof spec.measure === 'string') spec.measure = {kind: spec.measure};
return spec;
}
function svgDonut(groups, total, centerLabel) {
var size = 220, r = 84, cx = size/2, cy = size/2, acc = 0;
var paths = groups.map(function(g, i){
var frac = g.percent || 0;
var a0 = acc * Math.PI * 2 - Math.PI/2, a1 = (acc + frac) * Math.PI * 2 - Math.PI/2;
acc += frac;
var large = frac > 0.5 ? 1 : 0;
var x0 = cx + r*Math.cos(a0), y0 = cy + r*Math.sin(a0);
var x1 = cx + r*Math.cos(a1), y1 = cy + r*Math.sin(a1);
var xm = cx + (r+22)*Math.cos((a0+a1)/2), ym = cy + (r+22)*Math.sin((a0+a1)/2);
var anchor = xm > cx ? 'start' : 'end';
var label = groups.length <= 8 ? '<text x="'+xm.toFixed(1)+'" y="'+ym.toFixed(1)+'" text-anchor="'+anchor+'" font-size="11" fill="var(--text-secondary)">'+escHtml(g.count+' ('+fmtPct(g.percent||0)+')')+'</text>' : '';
if (frac <= 0) return label;
if (frac >= 0.9999) return '<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="'+g.color+'" stroke-width="30" data-drill="'+escHtml(g.key)+'"/>' + label;
return '<path d="M '+x0.toFixed(1)+' '+y0.toFixed(1)+' A '+r+' '+r+' 0 '+large+' 1 '+x1.toFixed(1)+' '+y1.toFixed(1)+'" fill="none" stroke="'+g.color+'" stroke-width="30" style="cursor:pointer" data-drill="'+escHtml(g.key)+'"><title>'+escHtml(g.label+': '+g.count)+'</title></path>' + label;
}).join('');
return '<svg viewBox="0 0 '+size+' '+size+'" style="max-width:340px;width:100%;height:auto" role="img" aria-label="Donut chart">' + paths +
'<text x="'+cx+'" y="'+(cy-4)+'" text-anchor="middle" font-size="30" font-weight="700" fill="var(--text-primary)">'+escHtml(fmtNum(total))+'</text>' +
'<text x="'+cx+'" y="'+(cy+16)+'" text-anchor="middle" font-size="11" fill="var(--text-dim)">'+escHtml(centerLabel || 'Total')+'</text></svg>';
}
function svgBars(groups, horizontal) {
var max = Math.max.apply(null, groups.map(function(g){ return g.value; }).concat([1]));
if (!horizontal) {
var bars = groups.map(function(g){
var hgt = Math.max(3, (g.value/max)*120);
return '<g style="cursor:pointer" data-drill="'+escHtml(g.key)+'"><title>'+escHtml(g.label+': '+fmtNum(g.value))+'</title>' +
'<rect x="0" y="'+(130-hgt).toFixed(1)+'" width="34" height="'+hgt.toFixed(1)+'" rx="4" fill="'+g.color+'"/>' +
'<text x="17" y="'+(126-hgt).toFixed(1)+'" text-anchor="middle" font-size="10" fill="var(--text-secondary)">'+escHtml(fmtNum(g.value))+'</text>' +
'<text x="17" y="144" text-anchor="middle" font-size="10" fill="var(--text-dim)">'+escHtml(String(g.label).slice(0,10))+'</text></g>';
}).join('');
return '<svg viewBox="0 0 '+(groups.length*48+10)+' 160" style="width:100%;height:auto;max-height:300px" role="img" aria-label="Bar chart">' +
groups.map(function(_, i){ return '<g transform="translate('+(10+i*48)+',0)">'+bars.split('</g>').join('</g>').split('<g')[1]; }).join('') + '</svg>' +
'<table style="position:absolute;left:-9999px"><caption>Data</caption>'+groups.map(function(g){ return '<tr><th>'+escHtml(g.label)+'</th><td>'+escHtml(String(g.value))+'</td></tr>'; }).join('')+'</table>';
}
var rows = groups.map(function(g){
var w = Math.max(2, (g.value/max)*100);
return '<div style="display:flex;align-items:center;gap:8px;margin:5px 0;cursor:pointer" data-drill="'+escHtml(g.key)+'" title="'+escHtml(g.label+': '+fmtNum(g.value))+'">' +
'<span style="width:130px;text-align:right;font-size:12px;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+escHtml(g.label)+'</span>' +
'<div style="flex:1;background:var(--bg-tertiary);border-radius:4px;height:18px"><div style="width:'+w.toFixed(1)+'%;background:'+g.color+';height:18px;border-radius:4px"></div></div>' +
'<span style="font-size:12px;min-width:44px">'+escHtml(fmtNum(g.value))+'</span></div>';
}).join('');
return '<div role="img" aria-label="Horizontal bar chart">'+rows+'</div>';
}
function svgLine(groups) {
var max = Math.max.apply(null, groups.map(function(g){ return g.value; }).concat([1]));
var W = 560, H = 200, P = 28;
var pts = groups.map(function(g, i){
var x = groups.length > 1 ? P + i*(W-2*P)/(groups.length-1) : W/2;
var y = H - P - (g.value/max)*(H-2*P);
return {x: x, y: y, g: g};
});
var line = pts.map(function(p, i){ return (i ? 'L' : 'M') + p.x.toFixed(1)+' '+p.y.toFixed(1); }).join(' ');
var dots = pts.map(function(p){ return '<circle cx="'+p.x.toFixed(1)+'" cy="'+p.y.toFixed(1)+'" r="4" fill="'+p.g.color+'" style="cursor:pointer" data-drill="'+escHtml(p.g.key)+'"><title>'+escHtml(p.g.label+': '+fmtNum(p.g.value))+'</title></circle>'; }).join('');
return '<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto;max-height:300px" role="img" aria-label="Line chart"><path d="'+line+'" fill="none" stroke="var(--accent,#2383E2)" stroke-width="2.5"/>' + dots + '</svg>';
}
function renderChart() {
var c = state.viewConfig || {};
var type = c.chart_type || 'doughnut';
var gprop = findProp(c.group_by || c.chart_property);
if (!gprop && type !== 'number') {
return '<div style="padding:28px;color:var(--text-dim)">Select a <b>Group by</b> property in ⚙ View settings to build the chart.</div>';
}
if (type === 'number') {
var agg = localAggregate(chartSpec());
var lbl = c.title || ((c.measure && c.measure.property) || state.collection.name || 'Total');
return '<div style="display:flex;justify-content:center;padding:36px 12px"><div style="max-width:420px;width:100%;background:var(--bg-secondary);border:1px solid var(--border);border-radius:14px;padding:36px;text-align:center">' +
'<div style="font-size:12px;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-dim);margin-bottom:10px">'+escHtml(lbl)+'</div>' +
'<div style="font-size:64px;font-weight:700;line-height:1">'+escHtml(fmtNum(agg.total))+'</div>' +
'<div style="font-size:12px;color:var(--text-dim);margin-top:12px">'+escHtml((c.measure && c.measure.kind) || 'count')+' · '+agg.scanned+' row(s)</div></div></div>';
}
var agg2 = localAggregate(chartSpec());
if (!agg2.groups.length) return '<div style="padding:28px;color:var(--text-dim)">No data — adjust filters. ('+agg2.scanned+' rows scanned)</div>';
var centerLbl = (c.style && c.style.center_value === 'sum') ? 'Sum' : 'Total';
var viz = type === 'bar' ? svgBars(agg2.groups, false) : type === 'bar_horizontal' ? svgBars(agg2.groups, true) : type === 'line' ? svgLine(agg2.groups) : svgDonut(agg2.groups, agg2.total, centerLbl);
var showLegend = !(c.style && c.style.legend === false);
var legend = showLegend ? '<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin-top:10px">' +
agg2.groups.map(function(g){ return '<button data-legend="'+escHtml(g.key)+'" title="Hide/show" style="display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--bg-secondary);border-radius:12px;padding:3px 10px;font-size:12px;cursor:pointer"><span style="width:9px;height:9px;border-radius:50%;background:'+g.color+'"></span>'+escHtml(g.label)+'</button>'; }).join('') + '</div>' : '';
var trunc = agg2.truncated ? '<div style="margin:8px auto;max-width:640px;background:rgba(217,115,13,.12);border:1px solid rgba(217,115,13,.4);border-radius:8px;padding:8px 12px;font-size:12px">200 groups shown — refine filters.</div>' : '';
var drill = state.drilldown ? '<div style="margin:12px auto;max-width:760px;border:1px solid var(--border);border-radius:10px;overflow:hidden"><div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg-secondary);font-size:13px"><b>'+escHtml(state.drilldown.group)+'</b><span style="color:var(--text-dim)">'+state.drilldown.rows.length+' rows</span><button data-drill-save="1" style="margin-left:auto;font-size:12px">Save as view</button><button data-drill-close="1" style="font-size:12px">✕</button></div>' +
state.drilldown.rows.slice(0, 30).map(function(cp){ return '<div style="padding:6px 12px;border-top:1px solid var(--border);font-size:13px;cursor:pointer" data-card-page="'+cp.id+'">'+escHtml(cp.icon&&cp.icon!=='file'?cp.icon+' ':'')+escHtml(cp.title||'Untitled')+'</div>'; }).join('') + '</div>' : '';
return '<div style="padding:18px 12px 26px;max-width:860px;margin:0 auto"><div style="display:flex;justify-content:flex-end;gap:8px;margin-bottom:6px"><button data-chart-export="1" style="font-size:12px" title="Export PNG">⬇ PNG</button></div>' +
'<div data-chart-viz>'+viz+'</div>' + legend + trunc + drill + '</div>';
}
// ── Vue Timeline (échelles, today, seau sans date, drag des dates) ──
var TL_SCALES = ['hour','day','week','month','quarter','year'];
function tlDateProp() { return findProp(state.viewConfig.date_property || state.viewConfig.date_range_property) || firstPropOfTypes(['date']); }
function tlParse(v) {
if (!v) return null;
var s = String(v);
var m = s.match(/(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2}))?/);
if (m) { var end = null; if (s.indexOf('...') >= 0) { var p2 = s.split('...')[1]; var m2 = p2.match(/(\d{4}-\d{2}-\d{2})/); if (m2) end = m2[1]; }
return {start: m[1], end: end || m[1]}; }
return null;
}
function renderTimeline() {
var dp = tlDateProp();
if (!dp) return '<div style="padding:24px;color:var(--text-dim)">Add a Date property to use the timeline view. <button data-tl-add-date="1">+ Add date property</button></div>';
var scale = TL_SCALES.indexOf(state.viewConfig.scale) >= 0 ? state.viewConfig.scale : 'month';
var dated = [], undated = [];
state.pages.forEach(function(cp){
var r = tlParse(pageVal(cp, dp));
(r ? dated : undated).push({cp: cp, range: r});
});
var lim = Number(state.viewConfig.load_limit) || 200;
var shown = dated.slice(0, lim);
var h = '<div style="padding:6px 2px 18px">';
h += '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">';
h += '<button class="db-btn-new" data-tl-today="1" style="padding:3px 10px">Today</button>';
h += '<select data-tl-scale title="Scale">' + TL_SCALES.map(function(s){ return '<option value="'+s+'"'+(s===scale?' selected':'')+'>'+s.charAt(0).toUpperCase()+s.slice(1)+'</option>'; }).join('') + '</select>';
h += '<button data-tl-nodate="1" style="font-size:12px">No date ('+undated.length+')</button>';
h += '<label style="font-size:12px;color:var(--text-secondary);margin-left:auto"><input type="checkbox" data-tl-table '+(state.viewConfig.show_table?'checked':'')+'> Table</label>';
h += '</div>';
if (state.tlShowUndated && undated.length) {
h += '<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:10px;max-width:640px">' + undated.slice(0,30).map(function(x){
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-top:1px solid var(--border);font-size:13px"><span style="flex:1;cursor:pointer" data-card-page="'+x.cp.id+'">'+escHtml(x.cp.title||'Untitled')+'</span><button data-tl-setdate="'+x.cp.id+'" style="font-size:12px">+ Set date</button></div>';
}).join('') + '</div>';
}
if (!dated.length) return h + '<div style="padding:24px;color:var(--text-dim)">No dated rows. Set a date on a row to draw its bar.</div></div>';
var all = dated.map(function(x){ return x.range.start; }).concat(dated.map(function(x){ return x.range.end; })).sort();
var min = all[0], max = all[all.length-1];
var t0 = new Date(min).getTime(), t1 = new Date(max).getTime();
if (t1 <= t0) t1 = t0 + 864e5;
var todayIso = new Date().toISOString().slice(0,10);
var todayLeft = Math.min(100, Math.max(0, (new Date(todayIso).getTime()-t0)/(t1-t0)*100));
// Tri : ordre persistant row_order puis titre.
var order = state.viewConfig.row_order || [];
shown.sort(function(a, b){
var ia = order.indexOf(a.cp.id), ib = order.indexOf(b.cp.id);
if (ia >= 0 || ib >= 0) return (ia < 0 ? 9999 : ia) - (ib < 0 ? 9999 : ib);
return String(a.cp.title||'').localeCompare(String(b.cp.title||''));
});
h += '<div style="position:relative;border:1px solid var(--border);border-radius:8px;overflow-x:auto;background:var(--bg-primary)">';
h += '<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-dim);padding:6px 12px;border-bottom:1px solid var(--border)"><span>'+escHtml(min)+'</span><span>'+escHtml(max)+'</span><span>'+shown.length+' / '+dated.length+'</span></div>';
h += '<div style="position:relative;min-width:560px">';
h += '<div title="Today" style="position:absolute;top:0;bottom:0;left:'+todayLeft.toFixed(2)+'%;width:2px;background:#e5484d;z-index:2"></div>';
shown.forEach(function(x){
var s = new Date(x.range.start).getTime(), e = new Date(x.range.end).getTime();
if (e < s) e = s;
var left = (s-t0)/(t1-t0)*100, width = Math.max((e-s)/(t1-t0)*100, 1.2);
var ro = READONLY_TYPES.indexOf(dp.prop_type) >= 0;
h += '<div style="display:flex;align-items:center;gap:10px;padding:5px 12px;border-bottom:1px solid var(--border)">';
h += '<span style="width:170px;flex-shrink:0;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" data-card-page="'+x.cp.id+'">'+escHtml(x.cp.title||'Untitled')+'</span>';
h += '<div style="position:relative;flex:1;height:26px;background:var(--bg-secondary);border-radius:5px" data-tl-track="'+x.cp.id+'">';
h += '<div data-tl-bar="'+x.cp.id+'" title="'+escHtml(x.range.start+' → '+x.range.end)+'" style="position:absolute;top:4px;height:18px;left:'+left.toFixed(2)+'%;width:'+width.toFixed(2)+'%;background:var(--accent,#2383E2);border-radius:4px;'+(ro?'opacity:.6':'cursor:grab')+'">' +
'<span data-tl-grip="l" style="position:absolute;left:0;top:0;bottom:0;width:8px;cursor:ew-resize"></span>' +
'<span data-tl-grip="r" style="position:absolute;right:0;top:0;bottom:0;width:8px;cursor:ew-resize"></span></div>';
h += '</div></div>';
});
h += '</div></div>';
if (state.viewConfig.show_table) {
var tprops = (state.viewConfig.table_properties || []).map(findProp).filter(Boolean);
if (!tprops.length) tprops = state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3);
h += '<div style="overflow-x:auto;margin-top:10px;border:1px solid var(--border);border-radius:8px"><table class="db-table"><thead><tr><th class="db-th">Page</th>' +
tprops.map(function(p){ return '<th class="db-th">'+escHtml(p.name)+'</th>'; }).join('') + '</tr></thead><tbody>' +
shown.map(function(x){ return '<tr class="db-row"><td class="db-td" style="cursor:pointer" data-card-page="'+x.cp.id+'">'+escHtml(x.cp.title||'Untitled')+'</td>' +
tprops.map(function(p){ return '<td class="db-td">'+escHtml(textValue(p, pageVal(x.cp,p)).slice(0,60))+'</td>'; }).join('') + '</tr>'; }).join('') +
'</tbody></table></div>';
}
h += '</div>';
return h;
}
// ── Vue Feed (cartes sociales : auteur, ancienneté, props, réactions) ──
function relTime(iso) {
if (!iso) return '';
var t = new Date(String(iso).length <= 10 ? String(iso)+'T12:00:00' : iso).getTime();
if (isNaN(t)) return '';
var d = Date.now() - t;
var m = Math.floor(d/6e4), hh = Math.floor(d/36e5), dd = Math.floor(d/864e5);
if (m < 1) return 'just now';
if (m < 60) return m+'m';
if (hh < 24) return hh+'h';
if (dd < 30) return dd+'d';
return String(iso).slice(0,10);
}
function feedAuthor(cp) {
var pv = pvOf(cp);
var cand = cp.last_edited_by || cp.created_by;
var keys = Object.keys(pv || {});
for (var i=0;i<keys.length;i++){ var v = pv[keys[i]];
if (v && typeof v === 'object' && !Array.isArray(v) && (v.login || v.full_name)) { cand = v; break; }
if (Array.isArray(v) && v.length && v[0] && (v[0].login || v[0].full_name)) { cand = v[0]; break; } }
var edited = cp.updated_at || cp.last_edited_time || cp.created_at;
return {who: cand, when: edited};
}
function renderFeed() {
var vis = state.viewConfig.visible_properties;
var vprops = Array.isArray(vis) && vis.length ? vis.map(findProp).filter(Boolean)
: state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3);
var rows = state.pages.slice().sort(function(a,b){ return String(b.updated_at||b.created_at||'') < String(a.updated_at||a.created_at||'') ? -1 : 1; });
var lim = state.feedLimit || 20;
var h = '<div style="max-width:640px;margin:0 auto;padding:10px 12px 30px;display:flex;flex-direction:column;gap:14px">';
rows.slice(0, lim).forEach(function(cp){
var au = feedAuthor(cp);
var who = au.who ? escHtml(au.who.full_name || au.who.login || '') : 'Someone';
var av = au.who ? avatarHtml(au.who, 22) : '<span class="db-person-avatar" style="width:22px;height:22px">?</span>';
h += '<article style="border:1px solid var(--border);border-radius:12px;background:var(--bg-primary);padding:14px 16px">';
h += '<header style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary)">'+av+'<span><b style="color:var(--text-primary)">'+who+'</b> · '+escHtml(relTime(au.when))+ (cp.updated_at && cp.updated_at !== cp.created_at ? ' (edited)' : '') +'</span>';
if (state.viewConfig.show_view_count !== false) h += '<span style="margin-left:auto" title="Views">👁 '+(cp.view_count||0)+'</span>';
h += '</header>';
h += '<h3 style="font-size:19px;margin:10px 0 6px;cursor:pointer" data-card-page="'+cp.id+'">'+escHtml(cp.title||'Untitled')+'</h3>';
if (vprops.length) h += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px">'+vprops.map(function(p){
var t = textValue(p, pageVal(cp,p));
return t ? '<span class="db-chip">'+escHtml(p.name+': '+String(t).slice(0,40))+'</span>' : '';
}).join('')+'</div>';
var excerpt = cp.excerpt || cp.content_snippet || '';
if (excerpt) h += '<p style="font-size:13px;color:var(--text-secondary);line-height:1.5">'+escHtml(String(excerpt).slice(0,280))+'</p>';
h += '<div style="display:flex;align-items:center;gap:10px;margin-top:8px">';
h += '<button data-feed-react="'+cp.id+'" style="font-size:13px;border:1px solid var(--border);background:transparent;border-radius:14px;padding:2px 10px;cursor:pointer">☺+ '+(state.feedReacts && state.feedReacts[cp.id] ? state.feedReacts[cp.id].length : '')+'</button>';
h += '<button data-feed-comment="'+cp.id+'" style="font-size:12px;color:var(--text-dim);border:none;background:transparent;cursor:pointer">💬 '+(cp.comment_count||'')+' Add a comment…</button>';
h += '</div></article>';
});
if (!rows.length) h += '<div style="padding:30px;text-align:center;color:var(--text-dim)">No posts yet — press <b>+ New</b> to publish the first one.</div>';
if (rows.length > lim) h += '<button data-feed-more="1" style="align-self:center;font-size:13px">Show more ('+(rows.length-lim)+' remaining)</button>';
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(); };
bindNotionViews();
}
// ── Vues Notion : câblage des interactions ──
function bindNotionViews() {
// Chart : légende, drilldown, export PNG.
containerEl.querySelectorAll('[data-legend]').forEach(function(b){
b.onclick = function(){
var k = b.getAttribute('data-legend');
state.chartHidden = state.chartHidden || {};
if (state.chartHidden[k]) delete state.chartHidden[k]; else state.chartHidden[k] = 1;
render();
};
});
containerEl.querySelectorAll('[data-chart-viz] [data-drill]').forEach(function(el){
el.onclick = function(e){
e.stopPropagation();
var key = el.getAttribute('data-drill');
var spec = chartSpec();
var gprop = findProp(spec.group_by);
var rows = state.pages.filter(function(cp){
if (!gprop) return true;
var t = textValue(gprop, pageVal(cp, gprop)) || '(empty)';
return String(t) === String(key);
});
state.drilldown = {group: key, rows: rows};
render();
};
});
containerEl.querySelectorAll('[data-drill-close]').forEach(function(b){ b.onclick = function(){ state.drilldown = null; render(); }; });
containerEl.querySelectorAll('[data-drill-save]').forEach(function(b){
b.onclick = function(){
if (!state.drilldown) return;
var spec = chartSpec();
fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body: JSON.stringify({name: 'Drilldown ' + state.drilldown.group, view_type: 'table', config: {filters: [{property: spec.group_by, operator: 'is', value: state.drilldown.group}]}})})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) { state.views.push(d); applyView(d); state.drilldown = null; render(); toast('Saved as view'); } });
};
});
var exp = containerEl.querySelector('[data-chart-export]');
if (exp) exp.onclick = function(){
var svg = containerEl.querySelector('[data-chart-viz] svg');
if (!svg) { toast('Nothing to export'); return; }
try {
var xml = new XMLSerializer().serializeToString(svg);
var img = new Image();
img.onload = function(){
var cv = document.createElement('canvas');
cv.width = 1200; cv.height = 800;
var ctx = cv.getContext('2d');
ctx.fillStyle = '#191919'; ctx.fillRect(0, 0, cv.width, cv.height);
ctx.drawImage(img, 0, 0, cv.width, cv.height);
var a = document.createElement('a');
a.download = 'chart.png'; a.href = cv.toDataURL('image/png'); a.click();
};
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml);
} catch (e) { toast('Export failed', 'error'); }
};
// Timeline.
containerEl.querySelectorAll('[data-tl-scale]').forEach(function(s){ s.onchange = function(){ persistConfig({scale: s.value}); }; });
var tlt = containerEl.querySelector('[data-tl-today]');
if (tlt) tlt.onclick = function(){ render(); toast('Centered on today'); };
var tln = containerEl.querySelector('[data-tl-nodate]');
if (tln) tln.onclick = function(){ state.tlShowUndated = !state.tlShowUndated; render(); };
var tlt2 = containerEl.querySelector('[data-tl-table]');
if (tlt2) tlt2.onchange = function(){ persistConfig({show_table: tlt2.checked}); };
var tla = containerEl.querySelector('[data-tl-add-date]');
if (tla) tla.onclick = function(){
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Due', prop_type:'date'})})
.then(function(r){ return r.json(); }).then(function(d){ if (d.id) state.properties.push(d); render(); });
};
containerEl.querySelectorAll('[data-tl-setdate]').forEach(function(b){
b.onclick = function(){
var cp = pageById(parseInt(b.getAttribute('data-tl-setdate')));
var dp = tlDateProp();
if (!cp || !dp) return;
var v = prompt('Date (YYYY-MM-DD):', new Date().toISOString().slice(0,10));
if (!v) return;
setPageVal(cp, dp, v); render(); savePage(cp);
};
});
// Drag des barres / poignées (écriture via savePage — principe 4).
containerEl.querySelectorAll('[data-tl-bar]').forEach(function(bar){
var pid = parseInt(bar.getAttribute('data-tl-bar'));
var cp = pageById(pid), dp = tlDateProp();
if (!cp || !dp || READONLY_TYPES.indexOf(dp.prop_type) >= 0) { bar.style.cursor = 'not-allowed'; return; }
bar.onpointerdown = function(e){
if (e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
var track = bar.parentElement;
var trect = track.getBoundingClientRect();
var rng = tlParse(pageVal(cp, dp)) || {start: new Date().toISOString().slice(0,10), end: new Date().toISOString().slice(0,10)};
var t0 = new Date(rng.start).getTime(), span = Math.max(new Date(rng.end).getTime() - t0, 864e5);
var grip = e.target.getAttribute('data-tl-grip');
var startX = e.clientX, origStart = t0, origEnd = new Date(rng.end).getTime();
var dayMs = 864e5;
function iso(ms){ return new Date(ms).toISOString().slice(0,10); }
function onMove(ev){
var dx = (ev.clientX - startX) / Math.max(trect.width, 1);
// Échelle temporelle de la vue complète pour convertir le geste.
var all = state.pages.map(function(p){ var r2 = tlParse(pageVal(p, dp)); return r2 ? [r2.start, r2.end] : null; }).filter(Boolean);
var flat = []; all.forEach(function(a){ flat.push(a[0], a[1]); }); flat.sort();
var vt0 = flat.length ? new Date(flat[0]).getTime() : t0;
var vt1 = flat.length ? new Date(flat[flat.length-1]).getTime() : t0 + span;
if (vt1 <= vt0) vt1 = vt0 + 864e5;
var delta = dx * (vt1 - vt0);
delta = Math.round(delta / dayMs) * dayMs;
var ns = origStart, ne = origEnd;
if (grip === 'l') ns = Math.min(origStart + delta, ne);
else if (grip === 'r') ne = Math.max(origEnd + delta, ns);
else { ns = origStart + delta; ne = origEnd + delta; }
bar.title = iso(ns) + ' → ' + iso(ne);
}
function onUp(ev){
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
var dx = (ev.clientX - startX) / Math.max(trect.width, 1);
var all2 = state.pages.map(function(p){ var r2 = tlParse(pageVal(p, dp)); return r2 ? [r2.start, r2.end] : null; }).filter(Boolean);
var flat2 = []; all2.forEach(function(a){ flat2.push(a[0], a[1]); }); flat2.sort();
var vt0 = flat2.length ? new Date(flat2[0]).getTime() : origStart;
var vt1 = flat2.length ? new Date(flat2[flat2.length-1]).getTime() : origEnd;
if (vt1 <= vt0) vt1 = vt0 + 864e5;
var delta = Math.round(dx * (vt1 - vt0) / dayMs) * dayMs;
if (!delta) { render(); return; }
var ns = origStart, ne = origEnd;
if (grip === 'l') ns = Math.min(origStart + delta, ne);
else if (grip === 'r') ne = Math.max(origEnd + delta, ns);
else { ns = origStart + delta; ne = origEnd + delta; }
function iso2(ms){ return new Date(ms).toISOString().slice(0,10); }
setPageVal(cp, dp, ns === ne ? iso2(ns) : iso2(ns) + '...' + iso2(ne));
render();
savePage(cp);
}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
};
});
// Feed.
containerEl.querySelectorAll('[data-feed-react]').forEach(function(b){
b.onclick = function(e){
e.stopPropagation();
var id = b.getAttribute('data-feed-react');
state.feedReacts = state.feedReacts || {};
var arr = state.feedReacts[id] || [];
var mine = arr.indexOf('you');
if (mine >= 0) arr.splice(mine, 1); else arr.push('you');
state.feedReacts[id] = arr;
render();
};
});
containerEl.querySelectorAll('[data-feed-comment]').forEach(function(b){
b.onclick = function(e){ e.stopPropagation(); openPageInSidePeek(parseInt(b.getAttribute('data-feed-comment'))); };
});
var fm = containerEl.querySelector('[data-feed-more]');
if (fm) fm.onclick = function(){ state.feedLimit = (state.feedLimit || 20) + 20; render(); };
// Map.
var mpBy = containerEl.querySelector('[data-map-by]');
if (mpBy) mpBy.onchange = function(){ var p = findProp(mpBy.value); persistConfig({place_property: p ? p.name : ''}); };
var mpAdd = containerEl.querySelector('[data-map-add-place]');
if (mpAdd) mpAdd.onclick = function(){
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Place', prop_type:'place'})})
.then(function(r){ return r.json(); }).then(function(d){ if (d.id) state.properties.push(d); render(); });
};
var mpRe = containerEl.querySelector('[data-map-recenter]');
if (mpRe) mpRe.onclick = function(){ bindMap(); };
if (state.viewType === 'map') bindMap();
// Dashboard.
containerEl.querySelectorAll('[data-dash-mode]').forEach(function(b){
b.onclick = function(){ state.dashMode = b.getAttribute('data-dash-mode'); render(); };
});
var dAdd = containerEl.querySelector('[data-dash-add]');
if (dAdd) dAdd.onclick = function(){ openDashAddWidget(dAdd); };
containerEl.querySelectorAll('[data-dash-del]').forEach(function(b){
b.onclick = function(){
var lid = b.getAttribute('data-dash-del');
state.dashWidgets = (state.dashWidgets || []).filter(function(w){ return String(w._lid) !== String(lid); });
render();
};
});
containerEl.querySelectorAll('[data-dash-dup]').forEach(function(b){
b.onclick = function(){
var lid = b.getAttribute('data-dash-dup');
var src = (state.dashWidgets || []).filter(function(w){ return String(w._lid) === String(lid); })[0];
if (src && (state.dashWidgets || []).length < 12) {
var copy = JSON.parse(JSON.stringify(src));
copy.id = null; copy._lid = 'l' + Date.now();
copy.title = (copy.title || 'Widget') + ' copy';
state.dashWidgets.push(copy); render();
}
};
});
containerEl.querySelectorAll('[data-dash-open]').forEach(function(b){
b.onclick = function(e){
e.preventDefault();
var lid = b.getAttribute('data-dash-open');
var w = (state.dashWidgets || []).filter(function(x){ return String(x._lid) === String(lid); })[0];
if (w && w.source_view_id) {
var target = state.views.filter(function(v){ return String(v.id) === String(w.source_view_id); })[0];
if (target) { applyView(target); render(); return; }
}
toast('Source view is in another base');
};
});
var dSave = containerEl.querySelector('[data-dash-save]');
if (dSave) dSave.onclick = function(){
var v = activeView();
if (!v) return;
var payload = (state.dashWidgets || []).map(function(w, i){
return {id: (typeof w.id === 'number' ? w.id : null), row_index: Math.floor(i/4), position: i,
col_span: w.col_span || 1, height_units: w.height_units || 2,
source_collection_id: w.source_collection_id || collectionId,
source_view_id: w.source_view_id || null,
inline_config: w.inline_config || {}};
});
fetch('/db/'+collectionId+'/views/'+v.id+'/widgets', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body: JSON.stringify({widgets: payload, global_filters: state.viewConfig.global_filters || []})})
.then(function(){ toast('Dashboard saved'); dashFetchWidgets().then(render); });
};
var dGfAdd = containerEl.querySelector('[data-dash-gf-add]');
if (dGfAdd) dGfAdd.onclick = function(){
var name = prompt('Property name for the global filter:');
if (!name) return;
var val = prompt('Value (contains):', '');
var gf = (state.viewConfig.global_filters || []).slice();
gf.push({property: name, operator: 'contains', value: val || '', excluded_widget_ids: []});
persistConfig({global_filters: gf});
};
containerEl.querySelectorAll('[data-dash-gf-del]').forEach(function(a){
a.onclick = function(e){
e.preventDefault();
var gf = (state.viewConfig.global_filters || []).slice();
gf.splice(parseInt(a.getAttribute('data-dash-gf-del')), 1);
persistConfig({global_filters: gf});
};
});
containerEl.querySelectorAll('[data-dash-tpl]').forEach(function(b){
b.onclick = function(){
var kind = b.getAttribute('data-dash-tpl');
if (kind === 'agent') { toast('✨ Ask the agent: “build me a status dashboard for this base”'); return; }
var g = firstPropOfTypes(['status','select']);
state.dashWidgets = [
{_lid: 'l1', title: 'By status', source_collection_id: collectionId, source_view_id: null, inline_config: {view_type: 'chart', group_by: g ? g.name : ''}},
{_lid: 'l2', title: 'All rows', source_collection_id: collectionId, source_view_id: null, inline_config: {view_type: 'table'}},
{_lid: 'l3', title: 'Milestones', source_collection_id: collectionId, source_view_id: null, inline_config: {view_type: 'table'}}
];
render();
};
});
if (state.viewType === 'dashboard') {
if (!state.dashWidgets) { dashFetchWidgets().then(function(){ if (state.viewType === 'dashboard') render(); }); }
else bindDashboard();
}
// Form header + builder.
containerEl.querySelectorAll('[data-form-preview]').forEach(function(b){
b.onclick = function(e){ e.preventDefault(); e.stopPropagation(); openFormPreview(); };
});
containerEl.querySelectorAll('[data-form-share]').forEach(function(b){
b.onclick = function(e){ e.preventDefault(); e.stopPropagation(); openFormShare(b); };
});
var ft = containerEl.querySelector('[data-form-title]');
if (ft) ft.onchange = function(){ persistConfig({title: ft.value}, {silent: true}); formSave(true); };
var fd = containerEl.querySelector('[data-form-desc]');
if (fd) fd.onchange = function(){ persistConfig({description: fd.value}, {silent: true}); formSave(true); };
containerEl.querySelectorAll('[data-fq-label]').forEach(function(inp){
inp.onchange = function(){
var card = inp.closest('[data-fq]');
var q = state.formQs[parseInt(card.getAttribute('data-fq'))];
if (!q) return;
var nv = inp.value.trim();
if (q.sync_label !== false && nv && nv !== q.property_name) {
if (confirm('Rename property “'+q.property_name+'” to “'+nv+'” everywhere?')) { q.label_override = nv; }
else { q.sync_label = false; q.label_override = nv; }
} else q.label_override = nv === q.property_name ? null : nv;
formSave(true).then(render);
};
});
containerEl.querySelectorAll('[data-fq-up]').forEach(function(b){
b.onclick = function(){
var i = parseInt(b.getAttribute('data-fq-up'));
if (i <= 0) return;
var t = state.formQs[i-1]; state.formQs[i-1] = state.formQs[i]; state.formQs[i] = t;
formSave(true).then(render);
};
});
containerEl.querySelectorAll('[data-fq-down]').forEach(function(b){
b.onclick = function(){
var i = parseInt(b.getAttribute('data-fq-down'));
if (i >= state.formQs.length - 1) return;
var t = state.formQs[i+1]; state.formQs[i+1] = state.formQs[i]; state.formQs[i] = t;
formSave(true).then(render);
};
});
containerEl.querySelectorAll('[data-fq-menu]').forEach(function(b){ b.onclick = function(){ openQuestionMenu(b); }; });
var fa = containerEl.querySelector('[data-form-addq]');
if (fa) fa.onclick = function(){
var name = prompt('New question / property name:');
if (!name) return;
var type = prompt('Type (text, number, select, date, checkbox, url, email, phone, place):', 'text') || 'text';
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name: name, prop_type: type})})
.then(function(r){ return r.json(); })
.then(function(d){
if (d.id) state.properties.push(d);
state.formQs.push({property_name: name, position: state.formQs.length, label_override: null, sync_label: true, help_text: '', required: false, widget: 'auto', max_selections: null, conditional: null});
formSave(true).then(render);
});
};
containerEl.querySelectorAll('[data-form-btn-text],[data-form-confirm-title],[data-form-confirm-body]').forEach(function(inp){
inp.onchange = function(){
var sub = Object.assign({}, state.viewConfig.submit_screen);
var bt = containerEl.querySelector('[data-form-btn-text]');
var ct = containerEl.querySelector('[data-form-confirm-title]');
var cb = containerEl.querySelector('[data-form-confirm-body]');
if (bt) sub.button_text = bt.value;
if (ct) sub.confirmation_title = ct.value;
if (cb) sub.confirmation_body = cb.value;
persistConfig({submit_screen: sub}, {silent: true}); formSave(true);
};
});
if (state.viewType === 'form' && !state.formQs) { formFetchQs().then(function(){ if (state.viewType === 'form') render(); }); }
}
function openDashAddWidget(anchor) {
var h = '<div class="pop-title">Add a widget</div>';
h += '<div class="pop-hint">Views of this base first, then another source, then a new inline view.</div>';
state.views.filter(function(v){ return v.view_type !== 'dashboard'; }).forEach(function(v){
h += '<div class="pop-item" data-dw="'+v.id+'">'+viewSvg(v.view_type)+' '+escHtml(v.name)+'</div>';
});
h += '<div class="pop-item" data-dw-new="chart">+ New chart for this dashboard</div>';
h += '<div class="pop-item" data-dw-new="table">+ New table for this dashboard</div>';
var pop = openPop(anchor, h);
function add(w){
state.dashWidgets = state.dashWidgets || [];
if (state.dashWidgets.length >= 12) { toast('12 widgets maximum'); return; }
w._lid = 'l' + Date.now();
state.dashWidgets.push(w); pop.remove(); render();
}
pop.querySelectorAll('[data-dw]').forEach(function(el){
el.onclick = function(){
var v = state.views.filter(function(x){ return String(x.id) === el.getAttribute('data-dw'); })[0];
if (v) add({title: v.name, source_collection_id: collectionId, source_view_id: v.id, inline_config: {}});
};
});
pop.querySelectorAll('[data-dw-new]').forEach(function(el){
el.onclick = function(){
var vt = el.getAttribute('data-dw-new');
var g = firstPropOfTypes(['status','select']);
add({title: 'New ' + vt, source_collection_id: collectionId, source_view_id: null,
inline_config: vt === 'chart' ? {view_type: 'chart', group_by: g ? g.name : ''} : {view_type: 'table'}});
};
});
}
function openQuestionMenu(anchor) {
var card = anchor.closest('[data-fq]');
var q = state.formQs[parseInt(card.getAttribute('data-fq'))];
if (!q) return;
var h = '<div class="pop-title">'+escHtml(q.property_name)+'</div>' +
'<div class="pop-row"><label>Required</label><select data-qm="required"><option value="0"'+(q.required?'':' selected')+'>No</option><option value="1"'+(q.required?' selected':'')+'>Yes</option></select></div>' +
'<div class="pop-row"><label>Help text</label><input type="text" data-qm-text value="'+escHtml(q.help_text||'')+'"></div>' +
'<div class="pop-row"><label>Label synced</label><select data-qm="sync"><option value="1"'+(q.sync_label!==false?' selected':'')+'>Yes</option><option value="0"'+(q.sync_label===false?' selected':'')+'>No</option></select></div>' +
'<div class="pop-row"><label>Condition (show if)</label><input type="text" data-qm-cond placeholder="Status = Done" value="'+escHtml(q.conditional ? (q.conditional.if_property+' = '+q.conditional.equals) : '')+'"></div>' +
'<div class="pop-item" data-qm-dup>⧉ Duplicate (new property)</div>' +
'<div class="pop-item" data-qm-del style="color:#e5484d">Remove from form</div>';
var pop = openPop(anchor, h);
pop.querySelector('[data-qm="required"]').onchange = function(){ q.required = this.value === '1'; formSave(true).then(render); };
pop.querySelector('[data-qm="sync"]').onchange = function(){ q.sync_label = this.value === '1'; if (q.sync_label) q.label_override = null; formSave(true).then(render); };
pop.querySelector('[data-qm-text]').onchange = function(){ q.help_text = this.value; formSave(true).then(render); };
pop.querySelector('[data-qm-cond]').onchange = function(){
var m = this.value.match(/^\s*(.+?)\s*=\s*(.+?)\s*$/);
q.conditional = m ? {if_property: m[1], equals: m[2], then: 'show'} : null;
formSave(true).then(render);
};
pop.querySelector('[data-qm-dup]').onclick = function(){
var base = q.property_name + ' 2';
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name: base, prop_type: 'text'})})
.then(function(){ state.formQs.push(Object.assign({}, q, {property_name: base, label_override: null})); pop.remove(); formSave(true).then(render); });
};
pop.querySelector('[data-qm-del]').onclick = function(){
if (!confirm('Remove “'+q.property_name+'” from the form? (property kept)')) return;
state.formQs = state.formQs.filter(function(x){ return x !== q; });
pop.remove(); formSave(true).then(render);
};
}
function openFormPreview() {
var c = state.viewConfig || {};
var h = '<div class="pop-title">'+escHtml(c.title || 'Form')+' (preview — not submittable)</div><div style="padding:12px 14px;display:flex;flex-direction:column;gap:10px;min-width:280px">';
(state.formQs || []).forEach(function(q){
if (q.conditional) h += '<div style="font-size:11px;color:var(--text-dim)">Shown if '+escHtml(q.conditional.if_property+' = '+q.conditional.equals)+'</div>';
h += '<label style="font-size:13px"><b>'+escHtml(q.label_override || q.property_name)+'</b>'+(q.required?' <span style="color:#e5484d">*</span>':'')+'<br><input type="text" disabled placeholder="Respondent\'s answer" style="width:100%;padding:6px 9px;border:1px solid var(--border);border-radius:6px;margin-top:4px"></label>';
});
var sub = c.submit_screen || {};
h += '<button disabled style="background:var(--accent,#2383E2);color:#fff;border:none;border-radius:6px;padding:8px;font-size:13px">'+escHtml(sub.button_text || 'Envoyer')+'</button></div>';
openPop(containerEl.querySelector('[data-form-preview]') || containerEl, h);
}
function openFormShare(anchor) {
var c = state.viewConfig || {};
var sh = c.sharing || {};
var h = '<div class="pop-title">Share form</div>' +
'<div class="pop-row"><label>Who can fill</label><select data-sh="audience">' +
[['workspace','Workspace members'],['web','Anyone with the link'],['closed','Closed']].map(function(o){ return '<option value="'+o[0]+'"'+(sh.audience===o[0]?' selected':'')+'>'+o[1]+'</option>'; }).join('') + '</select></div>' +
'<div class="pop-row"><label>Anonymous</label><select data-sh="anonymous"><option value="0"'+(sh.anonymous?'':' selected')+'>No</option><option value="1"'+(sh.anonymous?' selected':'')+'>Yes</option></select></div>' +
'<div class="pop-row"><label>Branding</label><select data-sh="branding"><option value="1"'+(sh.branding!==false?' selected':'')+'>Shown</option><option value="0"'+(sh.branding===false?' selected':'')+'>Hidden</option></select></div>' +
'<div class="pop-hint">Public link: /f/&lt;token&gt; (pipeline v6.8 unchanged). “Closed” answers 410.</div>';
var pop = openPop(anchor, h);
pop.querySelectorAll('[data-sh]').forEach(function(el){
el.onchange = function(){
var nsh = Object.assign({}, state.viewConfig.sharing);
if (el.getAttribute('data-sh') === 'audience') nsh.audience = el.value;
if (el.getAttribute('data-sh') === 'anonymous') nsh.anonymous = el.value === '1';
if (el.getAttribute('data-sh') === 'branding') nsh.branding = el.value === '1';
if (nsh.audience === 'web') nsh.anonymous = true;
persistConfig({sharing: nsh}, {silent: true}); formSave(true);
};
});
}
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; }
if (t === 'place') { openPlaceEditor(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="place">Place 📍</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 if (t === 'chart') {
h += '<div class="pop-row"><label>Type</label><select data-pop-field="chart_type">' + ['bar','bar_horizontal','line','doughnut','number'].map(function(x){ return '<option value="'+x+'"'+(state.viewConfig.chart_type===x?' selected':'')+'>'+x+'</option>'; }).join('') + '</select></div>';
h += '<div class="pop-row"><label>Group by (X)</label>'+propSelect('group_by', state.viewConfig.group_by || state.viewConfig.chart_property, function(){ return true; })+'</div>';
h += '<div class="pop-row"><label>Sub-group</label>'+propSelect('sub_group_by', state.viewConfig.sub_group_by, function(){ return true; })+'</div>';
h += '<div class="pop-row"><label>Measure (Y)</label><select data-pop-field="measure_kind">' + ['count','sum','average','median','min','max'].map(function(x){ var k = (state.viewConfig.measure&&state.viewConfig.measure.kind)||'count'; return '<option value="'+x+'"'+(k===x?' selected':'')+'>'+x+'</option>'; }).join('') + '</select></div>';
h += '<div class="pop-row"><label>Measure prop</label>'+propSelect('measure_prop', (state.viewConfig.measure&&state.viewConfig.measure.property)||'', function(p){ return p.prop_type==='number'; })+'</div>';
h += '<div class="pop-row"><label>Show empty groups</label><select data-pop-field="omit_zero"><option value="0"'+(state.viewConfig.omit_zero?'':' selected')+'>Yes</option><option value="1"'+(state.viewConfig.omit_zero?' selected':'')+'>No</option></select></div>';
h += '<div class="pop-row"><label>Cumulative</label><select data-pop-field="cumulative"><option value="0"'+(state.viewConfig.cumulative?'':' selected')+'>No</option><option value="1"'+(state.viewConfig.cumulative?' selected':'')+'>Yes</option></select></div>';
h += '<div class="pop-row"><label>Order</label><select data-pop-field="group_order">' + ['group_order','label','value'].map(function(x){ var o = state.viewConfig.group_order||(state.viewConfig.order&&state.viewConfig.order.by)||'group_order'; return '<option value="'+x+'"'+(o===x?' selected':'')+'>'+x+'</option>'; }).join('') + '</select></div>';
h += '<div class="pop-hint">Click a segment for the drilldown table. Chart is read-only (Notion parity).</div>';
} else if (t === 'timeline') {
h += '<div class="pop-row"><label>Date property</label>'+propSelect('date_property', state.viewConfig.date_property, function(p){ return p.prop_type==='date'; })+'</div>';
h += '<div class="pop-row"><label>Scale</label><select data-pop-field="scale">' + ['hour','day','week','month','quarter','year'].map(function(x){ return '<option value="'+x+'"'+(state.viewConfig.scale===x?' selected':'')+'>'+x+'</option>'; }).join('') + '</select></div>';
h += '<div class="pop-row"><label>Side table</label><select data-pop-field="show_table"><option value="0"'+(state.viewConfig.show_table?'':' selected')+'>Hidden</option><option value="1"'+(state.viewConfig.show_table?' selected':'')+'>Shown</option></select></div>';
h += '<div class="pop-row"><label>Load limit</label><input type="number" min="20" max="1000" data-pop-field="load_limit" value="'+(state.viewConfig.load_limit||200)+'"></div>';
h += '<div class="pop-hint">Drag bars / edges to rewrite dates. Vertical order is display-only.</div>';
} else if (t === 'feed') {
h += '<div class="pop-row"><label>Show view count</label><select data-pop-field="show_view_count"><option value="1"'+(state.viewConfig.show_view_count!==false?' selected':'')+'>Yes</option><option value="0"'+(state.viewConfig.show_view_count===false?' selected':'')+'>No</option></select></div>';
h += '<div class="pop-sep"></div><div class="pop-hint">Visible properties</div><div class="pop-chips">';
state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){
var vis = state.viewConfig.visible_properties;
var on = !vis || !vis.length ? true : vis.some(function(x){ return String(x)===String(p.id) || x===p.name; });
h += '<span class="pop-chip'+(on?' on':'')+'" data-feedprop="'+p.id+'">'+escHtml(p.name)+'</span>';
});
h += '</div>';
} else if (t === 'map') {
h += '<div class="pop-row"><label>Map by</label>'+propSelect('place_property', state.viewConfig.place_property, function(p){ return p.prop_type==='place'; })+'</div>';
h += '<div class="pop-hint">100 pins max. Click a pin to open the row. Unresolved places are counted, not plotted.</div>';
} else if (t === 'dashboard') {
h += '<div class="pop-hint">Widgets reference views of this or another base. 4 per row, 12 max. Global filters apply by property name to widgets that have it.</div>';
} else if (t === 'form') {
h += '<div class="pop-row"><label>Title</label><input type="text" data-pop-field="title" value="'+escHtml(state.viewConfig.title||'')+'"></div>';
h += '<div class="pop-hint">Build questions in the view (••• per question). Sync on = renaming edits the property.</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 f = this.dataset.popField, val = this.value, patch = {};
if (f === 'measure_kind') { persistConfig({measure: Object.assign({}, state.viewConfig.measure, {kind: val})}); return; }
if (f === 'measure_prop') { var p = findProp(val); persistConfig({measure: Object.assign({}, state.viewConfig.measure, {kind: (state.viewConfig.measure&&state.viewConfig.measure.kind)||'count', property: p ? p.name : null})}); return; }
if (f === 'omit_zero' || f === 'cumulative' || f === 'show_table' || f === 'show_view_count') { patch[f] = (val === '1'); persistConfig(patch); return; }
if (f === 'group_order') { persistConfig({group_order: val, order: {by: val, direction: 'asc'}}); return; }
if (f === 'load_limit') { patch[f] = Number(val) || 200; persistConfig(patch); return; }
patch[f] = val; persistConfig(patch);
};
});
pop.querySelectorAll('[data-feedprop]').forEach(function(el){
el.onclick = function(){
var id = this.dataset.feedprop;
var vis = (state.viewConfig.visible_properties || []).slice();
var idx = vis.findIndex(function(x){ return String(x) === String(id); });
if (idx >= 0) vis.splice(idx, 1); else vis.push(Number(id));
persistConfig({visible_properties: vis});
};
});
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 defaultConfigFor(t) {
if (t === 'chart') {
var g = firstPropOfTypes(['status','select']);
return {version:2, chart_type:'doughnut', group_by:(g && g.name) || '', measure:{kind:'count'}, omit_zero:false, style:{palette:'property', height:'medium', data_labels:'percent', legend:true, center_value:'total'}};
}
if (t === 'timeline') {
var dp = firstPropOfTypes(['date']);
return {version:2, date_property:(dp && dp.name) || '', scale:'month', show_table:false, table_properties:[], load_limit:200, row_order:[]};
}
if (t === 'feed') return {version:2, order:'last_edited_desc', show_view_count:true, excerpt:{enabled:true, max_chars:280}, visible_properties:[]};
if (t === 'map') {
var pp = firstPropOfTypes(['place']);
return {version:2, place_property:(pp && pp.name) || '', cluster:true, visible_properties:[]};
}
if (t === 'dashboard') return {version:2, global_filters:[], row_heights:{}, mode:'view'};
if (t === 'form') return {version:2, title:(state.collection.name||'Form'), description:'', sharing:{audience:'workspace', anonymous:false, respondent_access:'none', branding:true}, submit_screen:{button_text:'Envoyer', button_color:'blue', confirmation_title:'Merci !', confirmation_body:'Votre réponse a été enregistrée.'}};
return {};
}
function openAddViewMenu(anchor) {
var h = '<div class="pop-title">Add a view</div><div style="display:grid;grid-template-columns:1fr 1fr;gap:2px;padding:4px 8px 8px">';
FD_VIEW_ORDER.forEach(function(t){
var missing = '';
if (t === 'timeline' && !firstPropOfTypes(['date'])) missing = ' <span title="Needs a Date property" style="color:var(--text-dim);font-size:11px">· date needed</span>';
if (t === 'map' && !firstPropOfTypes(['place'])) missing = ' <span title="A Place property will be created" style="color:var(--text-dim);font-size:11px">· +place</span>';
h += '<div class="pop-item" data-addview="'+t+'" style="border-radius:6px">'+viewSvg(t)+' '+fdViewLabel(t)+missing+'</div>';
});
h += '</div>';
var pop = openPop(anchor, h);
pop.querySelectorAll('[data-addview]').forEach(function(el){
el.onclick = function(){
var t = this.dataset.addview; pop.remove();
// Prérequis de schéma (§7.3) : map sans place -> création assistée.
function createView(cfg) {
fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:fdViewLabel(t), view_type:t, config:cfg})})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } });
}
if (t === 'timeline' && !firstPropOfTypes(['date'])) {
if (confirm('Timeline needs a Date property. Create one now?')) {
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Due', prop_type:'date'})})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) state.properties.push(d); createView(defaultConfigFor(t)); });
}
return;
}
if (t === 'map' && !firstPropOfTypes(['place'])) {
if (!confirm('This view needs a Place property — create it?')) return;
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:'Place', prop_type:'place'})})
.then(function(r){ return r.json(); })
.then(function(d){ if (d.id) state.properties.push(d); createView(defaultConfigFor(t)); });
return;
}
createView(defaultConfigFor(t));
};
});
}
// ── 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;
});
}
})();
}