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 ''; case 'number': return ''; case 'select': return ''; case 'multi_select': return ''; case 'status': return ''; case 'date': return ''; case 'checkbox': return ''; case 'url': return ''; case 'email': return ''; case 'phone': return ''; case 'person': return ''; case 'files': return ''; case 'relation': case 'rollup': case 'formula': return ''; case 'created_time': case 'last_edited_time': return ''; case 'created_by': case 'last_edited_by': return ''; case 'place': return ''; default: return ''; } } 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,'"'); } 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 += '\u21bb'; }); } if (rem) { Object.keys(rem).forEach(function(k){ var r=rem[k]; if (r && r.unit && r.unit!=='none') h += '🔔'; }); } 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 ''; return ''+escHtml(initials(p.login||p.full_name))+''; } function displayValue(prop, val) { if (val === undefined || val === null || val === '') return '\u2014'; var t = prop.prop_type; if (t === 'checkbox') return ''+(val ? '✓' : '')+''; if (t === 'person' && Array.isArray(val)) { if (!val.length) return '\u2014'; return val.map(function(p){ return ''+avatarHtml(p)+escHtml(p.login||p.full_name||'')+''; }).join(''); } if ((t === 'select' || t === 'status')) { return ''+escHtml(val)+''; } if (t === 'multi_select' && Array.isArray(val)) { return val.map(function(v){ return ''+escHtml(v)+''; }).join('') || '\u2014'; } if (t === 'date') { return ''+escHtml(String(val).slice(0,10))+''; } if (t === 'created_time' || t === 'last_edited_time') { return ''+escHtml(String(val).slice(0,16).replace('T',' '))+''; } if (t === 'created_by' || t === 'last_edited_by') { return ''+avatarHtml(val)+escHtml(val.login||val.full_name||'')+''; } 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 = ''; var sub = (pname && paddr && pname !== paddr) ? ' · '+escHtml(String(paddr).slice(0,40))+'' : ''; return ''+pin+''+escHtml(String(plabel).slice(0,48))+''+sub+''; } if (t === 'url' && val) return ''+escHtml(val)+''; if (t === 'email' && val) return ''+escHtml(val)+''; if (t === 'relation' && Array.isArray(val)) return ''+escHtml(val.join(', '))+''; return ''+escHtml(Array.isArray(val)?val.join(', '):String(val))+''; } 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:'', board:'', calendar:'', gallery:'', list:'', chart:'', timeline:'', feed:'', map:'', dashboard:'', form:'' }; function viewSvg(t) { return ''+(VIEW_ICONS[t]||VIEW_ICONS.table)+''; } 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 ''; } function renderViewBar() { var h = '
'; if (!state.views.length) { h += ''; } state.views.forEach(function(v){ var active = v.id === state.activeViewId; h += ''; }); h += ''; h += '
'; var actions = taskDbButton(state.collection); if (state.viewType === 'form') { actions += '' + ''; } else { actions += ''; } h += '
' + actions + '
'; h += '
'; 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 = '
'; h += ''; if (hasGroups) { h += ''; h += ''; groups.forEach(function(g, gi){ var collapsed = state.collapsedGroups[gi]; h += ''; }); h += ''; h += ''; } h += ''; h += ''; 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 += ''; }); h += ''; h += ''; h += ''; pages.forEach(function(cp){ h += ''; h += ''; 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 += ''; }); h += ''; h += ''; }); var emptyNeeded = Math.max(0, 4 - pages.length); for (var i=0;i'; h += '
'; if (g.name) { h += '▾'+escHtml(g.name)+''; } h += '
'; h += '
'; h += ''+propTypeSvg(prop.prop_type)+''; h += ''+escHtml(prop.name)+''; h += '
'; h += '
'; h += displayValue(prop, val); if (prop.prop_type === 'date') h += recBadgeHtml(cp); if (prop.prop_type === 'title') h += ''; h += '
'; 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 = '
'; body += '
'+(cp.icon&&cp.icon!=='file'?escHtml(cp.icon)+' ':'')+escHtml(cp.title||'Untitled')+'
'; 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 += '
'+escHtml(p.name)+''+escHtml(txt.slice(0,60))+'
'; }); body += '
'; return '
'+cover+body+'
'; } 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 '
'; 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 '
'; } } if (mode === 'color') { var g = groupProp(); var key = g ? columnKeyFor(g, cp) : ''; var col = g ? optColor(g, key) : '#8b8b8b'; return '
'; } return '
'+escHtml(cp.icon && cp.icon!=='file' ? cp.icon : '📄')+'
'; } function renderBoard() { var g = groupProp(); if (!g) return '
Add a Select, Status or Person property to group the board.
'; var sub = findProp(state.viewConfig.sub_group_by); var cols = optionColumns(g); var wip = state.viewConfig.wip_limits || {}; var h = '
'; 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 += '
'+escHtml(lane.label)+' '+lanePages.length+'
'; h += boardColumnsHtml(g, cols, lanePages, wip); h += '
'; }); } else { h += boardColumnsHtml(g, cols, state.pages, wip); } h += '
'; return h; } function boardColumnsHtml(g, cols, pages, wip) { var h = '
'; 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 ? '' : ''); h += '
'; h += '
'+dot+''+escHtml(col.label)+''+colPages.length+''; if (limit) h += 'WIP '+colPages.length+'/'+limit+''; h += '
'; h += '
'; colPages.forEach(function(cp){ h += cardHtml(cp); }); h += ''; h += '
'; }); h += '
'; 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 ? '\u21bb' : ''; return '
' + (ev.time ? ''+escHtml(ev.time)+'' : '') + (ev.icon && ev.icon !== 'file' ? escHtml(ev.icon)+' ' : '') + escHtml(ev.title || 'Untitled') + badge + '
'; } function renderCalendar() { var dp = dateProp(); if (!dp) return '
Add a Date property to use the calendar view.
'; var mode = calMode(); var cur = state.calCursor; var h = '
'; h += '
'; 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 += '
'+title+'
'; h += ''; h += '
'+ ['day','week','month'].map(function(m){ return ''; }).join('')+ '
'; h += '
'; h += '
Loading\u2026
'; 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 += '
'; CAL_DOW.forEach(function(d){ h += '
'+d+'
'; }); for (var i=0;i'; h += '
'+cellDate.getDate()+'
'; (byDate[iso]||[]).forEach(function(ev){ h += evChipHtml(ev); }); h += '
'; } h += ''; } else if (win.mode === 'week') { h += '
'; CAL_DOW.forEach(function(d){ h += '
'+d+'
'; }); for (var j=0;j<7;j++) { var d2 = new Date(win.start.getTime() + j*864e5); var iso2 = isoDay(d2); h += '
'; h += '
'+d2.getDate()+'
'; (byDate[iso2]||[]).forEach(function(ev){ h += evChipHtml(ev); }); h += '
'; } h += '
'; } else { var iso3 = isoDay(win.start); h += '
'; h += '
'+CAL_DOW[(win.start.getDay()+6)%7]+' '+MONTH_NAMES[win.start.getMonth()]+' '+win.start.getDate()+(iso3===todayIso?' \u00b7 today':'')+'
'; (byDate[iso3]||[]).slice().sort(function(a,b){ return (a.time||'99').localeCompare(b.time||'99'); }).forEach(function(ev){ h += '
' + ''+(ev.time || '\u2014')+'' + ''+(ev.icon && ev.icon!=='file' ? escHtml(ev.icon)+' ' : '')+escHtml(ev.title||'Untitled')+'' + (ev.recurring ? '\u21bb' : '') + '
'; }); if (!(byDate[iso3]||[]).length) h += '
No events
'; h += '
'; } 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 = '
'+escHtml(cp.title||'Untitled')+'
'; h += '
'; h += '
'; h += '
'; if (freq) { h += '
'; h += '
'; if (rec.count != null) h += '
'; if (rec.until) h += '
'; } h += '
'; h += '
'; h += '
'; 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 = ''; 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 = '
'; state.pages.forEach(function(cp){ h += '
'; h += ''+escHtml(cp.icon&&cp.icon!=='file'?cp.icon:'📄')+''; h += ''+escHtml(cp.title||'Untitled')+''; props.forEach(function(p){ h += ''+escHtml(textValue(p, pageVal(cp,p)).slice(0,40))+''; }); h += '
'; }); h += '
'; 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 '
This view needs a Place property.
'; 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 = '
'; h += '
'; h += ''; h += ''+pts.length+' places · '+shown.length+' shown'+(nores ? ' · '+nores+' without coordinates' : '')+''; h += '
'; if (over) h += '
'+pts.length+' places — only the first 100 are shown. Refine filters.
'; h += '
'; h += '
' + shown.map(function(p){ return '
📍 '+escHtml(p.cp.title||'Untitled')+' '+escHtml(p.label||'')+' · '+p.lat.toFixed(3)+', '+p.lng.toFixed(3)+'
'; }).join('') + '
'; h += '
'+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,'"')+'
'; h += '
'; 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(''+g.length+' places
'+g.slice(0,5).map(function(p){ return escHtml(p.title); }).join('
')) : window.L.marker([g[0].lat, g[0].lng]).bindPopup(''+escHtml(g[0].title)+'
'+escHtml(g[0].label||'')+'
Open →'); 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 = '
'; h += '
'; h += '
' + '' + '
'; h += ''+widgets.length+' / 12 widgets'; if (mode === 'edit') h += ''; h += '
'; if (gf.length) h += '
Global filters:' + gf.map(function(f, i){ return ''+escHtml(f.property+' '+f.operator+' '+JSON.stringify(f.value))+' ✕'; }).join('') + (mode === 'edit' ? '' : '') + '
'; else if (mode === 'edit') h += '
'; if (!widgets.length) { h += '
'; h += ''; h += ''; h += ''; h += '
'; h += '
'; 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 += '
'; row.forEach(function(cell){ var w = cell.w; var title = escHtml(w.title || ('Widget '+(cell.i+1))); h += '
'; h += '
≡'+title+''; if (mode === 'edit') h += ''; else h += ''; h += '
Loading…
'; }); h += '
'; }); if (mode === 'edit' && widgets.length < 12) h += '
'; h += ''; 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 = '
'+escHtml(fmtNum(d.total))+'
' + d.groups.slice(0,6).map(function(g){ return '
'+escHtml(g.label)+'
'+g.value+'
'; }).join(''); } else { var rows = d.rows || []; slot.innerHTML = rows.slice(0,8).map(function(cp){ return '
'+escHtml(cp.title||'Untitled')+'
'; }).join('') || 'No rows'; if ((d.total||rows.length) > 8) slot.innerHTML += '
'+d.total+' rows — open view
'; 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 = '
'; h += '
'; h += ''; h += ''; h += '
🔒 '+escHtml(scopeTxt)+' Change
'; h += '
'; qs.forEach(function(q, i){ h += '
'; h += '
'; h += '
'; h += '
Respondent\'s answer'+(q.required?' *':'')+'
'; if (q.help_text) h += '
'+escHtml(q.help_text)+'
'; if (q.sync_label === false) h += '
Label unsynced from property “'+escHtml(q.property_name)+'”
'; h += '
'; }); h += '
'; h += ''; h += '
Confirmation screen'; var sub = c.submit_screen || {}; h += '
'; h += '
'; h += '
'; 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 = '
📍 Place
' + '
' + '
' + '
' + '
' + '
'; 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 '
📍 '+escHtml(s.name||'')+'
'+escHtml((s.address||'').slice(0,80))+'
'; }).join('') || '
No suggestion — free text will be kept as an unresolved place.
'; 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 ? ''+escHtml(g.count+' ('+fmtPct(g.percent||0)+')')+'' : ''; if (frac <= 0) return label; if (frac >= 0.9999) return '' + label; return ''+escHtml(g.label+': '+g.count)+'' + label; }).join(''); return '' + paths + ''+escHtml(fmtNum(total))+'' + ''+escHtml(centerLabel || 'Total')+''; } 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 ''+escHtml(g.label+': '+fmtNum(g.value))+'' + '' + ''+escHtml(fmtNum(g.value))+'' + ''+escHtml(String(g.label).slice(0,10))+''; }).join(''); return '' + groups.map(function(_, i){ return ''+bars.split('').join('').split('' + ''+groups.map(function(g){ return ''; }).join('')+'
Data
'+escHtml(g.label)+''+escHtml(String(g.value))+'
'; } var rows = groups.map(function(g){ var w = Math.max(2, (g.value/max)*100); return '
' + ''+escHtml(g.label)+'' + '
' + ''+escHtml(fmtNum(g.value))+'
'; }).join(''); return '
'+rows+'
'; } 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 ''+escHtml(p.g.label+': '+fmtNum(p.g.value))+''; }).join(''); return '' + dots + ''; } 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 '
Select a Group by property in ⚙ View settings to build the chart.
'; } if (type === 'number') { var agg = localAggregate(chartSpec()); var lbl = c.title || ((c.measure && c.measure.property) || state.collection.name || 'Total'); return '
' + '
'+escHtml(lbl)+'
' + '
'+escHtml(fmtNum(agg.total))+'
' + '
'+escHtml((c.measure && c.measure.kind) || 'count')+' · '+agg.scanned+' row(s)
'; } var agg2 = localAggregate(chartSpec()); if (!agg2.groups.length) return '
No data — adjust filters. ('+agg2.scanned+' rows scanned)
'; 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 ? '
' + agg2.groups.map(function(g){ return ''; }).join('') + '
' : ''; var trunc = agg2.truncated ? '
200 groups shown — refine filters.
' : ''; var drill = state.drilldown ? '
'+escHtml(state.drilldown.group)+''+state.drilldown.rows.length+' rows
' + state.drilldown.rows.slice(0, 30).map(function(cp){ return '
'+escHtml(cp.icon&&cp.icon!=='file'?cp.icon+' ':'')+escHtml(cp.title||'Untitled')+'
'; }).join('') + '
' : ''; return '
' + '
'+viz+'
' + legend + trunc + drill + '
'; } // ── 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 '
Add a Date property to use the timeline view.
'; 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 = '
'; h += '
'; h += ''; h += ''; h += ''; h += ''; h += '
'; if (state.tlShowUndated && undated.length) { h += '
' + undated.slice(0,30).map(function(x){ return '
'+escHtml(x.cp.title||'Untitled')+'
'; }).join('') + '
'; } if (!dated.length) return h + '
No dated rows. Set a date on a row to draw its bar.
'; 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 += '
'; h += '
'+escHtml(min)+''+escHtml(max)+''+shown.length+' / '+dated.length+'
'; h += '
'; h += '
'; 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 += '
'; h += ''+escHtml(x.cp.title||'Untitled')+''; h += '
'; h += '
' + '' + '
'; h += '
'; }); h += '
'; 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 += '
' + tprops.map(function(p){ return ''; }).join('') + '' + shown.map(function(x){ return '' + tprops.map(function(p){ return ''; }).join('') + ''; }).join('') + '
Page'+escHtml(p.name)+'
'+escHtml(x.cp.title||'Untitled')+''+escHtml(textValue(p, pageVal(x.cp,p)).slice(0,60))+'
'; } h += ''; 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?'; h += '
'; h += '
'+av+''+who+' · '+escHtml(relTime(au.when))+ (cp.updated_at && cp.updated_at !== cp.created_at ? ' (edited)' : '') +''; if (state.viewConfig.show_view_count !== false) h += '👁 '+(cp.view_count||0)+''; h += '
'; h += '

'+escHtml(cp.title||'Untitled')+'

'; if (vprops.length) h += '
'+vprops.map(function(p){ var t = textValue(p, pageVal(cp,p)); return t ? ''+escHtml(p.name+': '+String(t).slice(0,40))+'' : ''; }).join('')+'
'; var excerpt = cp.excerpt || cp.content_snippet || ''; if (excerpt) h += '

'+escHtml(String(excerpt).slice(0,280))+'

'; h += '
'; h += ''; h += ''; h += '
'; }); if (!rows.length) h += '
No posts yet — press + New to publish the first one.
'; if (rows.length > lim) h += ''; h += ''; 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 = '
Add a widget
'; h += '
Views of this base first, then another source, then a new inline view.
'; state.views.filter(function(v){ return v.view_type !== 'dashboard'; }).forEach(function(v){ h += '
'+viewSvg(v.view_type)+' '+escHtml(v.name)+'
'; }); h += '
+ New chart for this dashboard
'; h += '
+ New table for this dashboard
'; 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 = '
'+escHtml(q.property_name)+'
' + '
' + '
' + '
' + '
' + '
⧉ Duplicate (new property)
' + '
Remove from form
'; 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 = '
'+escHtml(c.title || 'Form')+' (preview — not submittable)
'; (state.formQs || []).forEach(function(q){ if (q.conditional) h += '
Shown if '+escHtml(q.conditional.if_property+' = '+q.conditional.equals)+'
'; h += ''; }); var sub = c.submit_screen || {}; h += '
'; openPop(containerEl.querySelector('[data-form-preview]') || containerEl, h); } function openFormShare(anchor) { var c = state.viewConfig || {}; var sh = c.sharing || {}; var h = '
Share form
' + '
' + '
' + '
' + '
Public link: /f/<token> (pipeline v6.8 unchanged). “Closed” answers 410.
'; 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 = '' + parseOpts(prop).map(function(o){ return ''+escHtml(o.name)+''; }).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 '
'+avatarHtml(m)+''+escHtml(m.full_name||m.login)+''+(checked?'✓':'')+'
'; }).join('') || '
No members
'; 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 = '
'+ '

Add a property

'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'; 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 = '
'+propTypeSvg(propType)+''+escHtml(propName)+'ⓘ
'+ '
'+ '
≡ Filter
'+ '
↕ Sort
'+ '
▦ Group by this
'+ '
🗂 Move to group…
'+ '
'+ '
├← Insert left
'+ '
→┤ Insert right
'; 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 ''; }).join(''); return ''; } function openViewSettings(anchor) { var v = activeView(); var t = state.viewType; var h = '
'+viewSvg(t)+' View settings
'; h += '
'; if (t === 'board') { var g = groupProp(); h += '
'+propSelect('group_by', state.viewConfig.group_by || (g?g.id:''), function(p){ return ['select','status','person','multi_select','text'].indexOf(p.prop_type)>=0; })+'
'; h += '
'+propSelect('sub_group_by', state.viewConfig.sub_group_by, function(p){ return ['select','status','person'].indexOf(p.prop_type)>=0; })+'
'; h += '
'; h += '
'; if (state.viewConfig.cover_mode === 'property') h += '
'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'
'; h += '
WIP limits (leave empty for none)
'; optionColumns(g).forEach(function(col){ if (col.key === '__none__') return; var lim = (state.viewConfig.wip_limits||{})[col.key] || ''; h += '
'; }); h += '
Card properties
'; 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 += ''+escHtml(p.name)+''; }); h += '
'; } else if (t === 'calendar') { h += '
'+propSelect('date_property', state.viewConfig.date_property, function(p){ return p.prop_type==='date'; })+'
'; } else if (t === 'gallery') { h += '
'; h += '
'; if (state.viewConfig.cover_mode === 'property') h += '
'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'
'; h += '
Card properties
'; 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 += ''+escHtml(p.name)+''; }); h += '
'; } else if (t === 'table') { h += '
Assign properties to collapsible groups. Group headers appear above the table.
'; state.properties.forEach(function(p){ h += '
'; }); } else if (t === 'chart') { h += '
'; h += '
'+propSelect('group_by', state.viewConfig.group_by || state.viewConfig.chart_property, function(){ return true; })+'
'; h += '
'+propSelect('sub_group_by', state.viewConfig.sub_group_by, function(){ return true; })+'
'; h += '
'; h += '
'+propSelect('measure_prop', (state.viewConfig.measure&&state.viewConfig.measure.property)||'', function(p){ return p.prop_type==='number'; })+'
'; h += '
'; h += '
'; h += '
'; h += '
Click a segment for the drilldown table. Chart is read-only (Notion parity).
'; } else if (t === 'timeline') { h += '
'+propSelect('date_property', state.viewConfig.date_property, function(p){ return p.prop_type==='date'; })+'
'; h += '
'; h += '
'; h += '
'; h += '
Drag bars / edges to rewrite dates. Vertical order is display-only.
'; } else if (t === 'feed') { h += '
'; h += '
Visible properties
'; 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 += ''+escHtml(p.name)+''; }); h += '
'; } else if (t === 'map') { h += '
'+propSelect('place_property', state.viewConfig.place_property, function(p){ return p.prop_type==='place'; })+'
'; h += '
100 pins max. Click a pin to open the row. Unresolved places are counted, not plotted.
'; } else if (t === 'dashboard') { h += '
Widgets reference views of this or another base. 4 per row, 12 max. Global filters apply by property name to widgets that have it.
'; } else if (t === 'form') { h += '
'; h += '
Build questions in the view (••• per question). Sync on = renaming edits the property.
'; } else { h += '
List uses the first properties. Configure card properties in a Board or Gallery view.
'; } h += '
'; h += '
⧉ Duplicate view
'; h += '
🗑 Delete view
'; 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 = '
Add a view
'; FD_VIEW_ORDER.forEach(function(t){ var missing = ''; if (t === 'timeline' && !firstPropOfTypes(['date'])) missing = ' · date needed'; if (t === 'map' && !firstPropOfTypes(['place'])) missing = ' · +place'; h += '
'+viewSvg(t)+' '+fdViewLabel(t)+missing+'
'; }); h += '
'; 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 = '
' + '
' + '' + '
' + '' + '
' + '
' + '
Chargement…
' + '' + '
'; 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 = '

Impossible d’ouvrir le contenu de cette ligne

'; } 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; }); } })(); }