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/.
2610 lines
192 KiB
JavaScript
2610 lines
192 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
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(/"/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/<token> (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;
|
||
});
|
||
}
|
||
})();
|
||
}
|