3 familles, 13 fichiers (+153/−167) :
1. no-empty ×70 = TOUS des `catch (x) {}` vides → `catch { /* volontaire */ }`
(binding optionnel ES2019 + commentaire : passe no-empty ET
no-unused-vars, zéro changement de comportement).
2. no-unused-vars ×171 :
- bindings de catch inutilisés retirés (e/err/ex/e2/e3)
- 24 lignes mortes déterministes, chaque suppression validée par assert
sur le texte exact (`var self = this` ×8, `var lang`, `var acc`,
`var today`, `var path/restored/files/resolved/items/clickEl`,
`uid()`/`propName()` sans un seul appel, `.then` + `resolved++`
compteurs jamais lus)
- `/* exported */` sur les 10 fonctions appelées depuis les attributs
HTML des templates (vérifiées par grep : 1 template chacune) :
setActiveTab/kanbanBoard/filterSystem/sortSystem/newIssueForm/
showNewIssue, importWizard, libraryPage, workspacesPage, settingsInit
3. no-undef ×44 = vrais globaux déclarés dans eslint.config.mjs
(getSvgIcon = script inline de base.html, TextDecoder = API navigateur,
Prism = CDN) + 2 vrais correctifs :
- settings.js : `typeof toast === 'function'` = guard TOUJOURS faux
(pas de toast global) → les toasts timezone/SAML ne s'affichaient
jamais → `window.showToast` (2 sites)
- local_workspace.js : `_wsInitData = window._wsInitData`
(auto-affectation sans effet, global implicite) supprimé
eslint static/js : **0 erreur / 0 warning** (285 → 0) · node --check vert
sur tous les fichiers · suite **1089/1089** · ruff OK · docs à jour
1313 lines
90 KiB
JavaScript
1313 lines
90 KiB
JavaScript
const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch{return null}})();
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// ── SVG Outline Icons ──
|
||
function propTypeSvg(type) {
|
||
const s = 14;
|
||
switch(type) {
|
||
case 'text': case 'title':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
|
||
case 'number':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="4" x2="18" y2="20"/><line x1="18" y1="4" x2="6" y2="20"/></svg>';
|
||
case 'select':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/></svg>';
|
||
case 'multi_select':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><rect x="2" y="5" width="4" height="2" rx="0.5"/><rect x="2" y="11" width="4" height="2" rx="0.5"/><rect x="2" y="17" width="4" height="2" rx="0.5"/></svg>';
|
||
case 'status':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 9 9"/></svg>';
|
||
case 'date':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||
case 'checkbox':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||
case 'url':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||
case 'email':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||
case 'phone':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
|
||
case 'person':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
||
case 'files':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
|
||
case 'relation': case 'rollup': case 'formula':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><line x1="8" y1="12" x2="16" y2="12"/></svg>';
|
||
case 'created_time': case 'last_edited_time':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
|
||
case 'created_by': case 'last_edited_by':
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>';
|
||
default:
|
||
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
|
||
}
|
||
}
|
||
|
||
var AUTO_TYPES = ['created_time','created_by','last_edited_time','last_edited_by'];
|
||
var READONLY_TYPES = AUTO_TYPES.concat(['rollup','formula']);
|
||
var PALETTE = {gray:'#8b8b8b',brown:'#9f6b53',orange:'#d9730d',yellow:'#cb912f',green:'#448361',blue:'#337ea9',purple:'#9065b0',pink:'#c14c8a',red:'#d44c47'};
|
||
|
||
function escHtml(s) { return String(s==null?'':s).replace(/&/g,'&').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 getCsrf() { var m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
|
||
function toast(msg, kind) {
|
||
if (typeof window.showToast === 'function') window.showToast(msg, kind);
|
||
else if (kind === 'error') console.warn(msg);
|
||
}
|
||
|
||
function pvOf(cp) { return parseJSON(cp.property_values_json, {}) || {}; }
|
||
function pageVal(cp, prop) {
|
||
if (prop.prop_type === 'title') return cp.title || '';
|
||
var pv = pvOf(cp);
|
||
var v = pv[String(prop.id)];
|
||
if (v === undefined) v = pv[prop.name];
|
||
return v;
|
||
}
|
||
function setPageVal(cp, prop, val) {
|
||
var pv = pvOf(cp);
|
||
pv[String(prop.id)] = val;
|
||
cp.property_values_json = pv;
|
||
if (prop.prop_type === 'title') cp.title = val;
|
||
}
|
||
|
||
// ── v5.8.0: recurrence / reminder meta keys ──
|
||
function metaOf(cp, key) {
|
||
var m = pvOf(cp)[key];
|
||
return (m && typeof m === 'object' && !Array.isArray(m)) ? m : null;
|
||
}
|
||
function setMeta(cp, key, propId, val) {
|
||
var pv = pvOf(cp);
|
||
var m = pv[key];
|
||
if (!m || typeof m !== 'object' || Array.isArray(m)) m = {};
|
||
if (val === null || val === undefined) delete m[String(propId)];
|
||
else m[String(propId)] = val;
|
||
if (Object.keys(m).length) pv[key] = m; else delete pv[key];
|
||
cp.property_values_json = pv;
|
||
}
|
||
function recBadgeHtml(cp) {
|
||
var rec = metaOf(cp, '__recurrence__');
|
||
var rem = metaOf(cp, '__reminder__');
|
||
var h = '';
|
||
if (rec) { Object.keys(rec).forEach(function(k){ if (rec[k] && rec[k].freq) h += '<span class="db-ev-badge" title="Repeat: '+escHtml(rec[k].freq)+'">\u21bb</span>'; }); }
|
||
if (rem) { Object.keys(rem).forEach(function(k){ var r=rem[k]; if (r && r.unit && r.unit!=='none') h += '<span class="db-ev-badge" title="Reminder: '+escHtml(String(r.value)+' '+r.unit)+' before">🔔</span>'; }); }
|
||
return h;
|
||
}
|
||
|
||
// ── Value display (type-aware) ──
|
||
function initials(s) { s = (s||'?').trim(); return s ? s.charAt(0).toUpperCase() : '?'; }
|
||
function avatarHtml(p, size) {
|
||
size = size || 16;
|
||
var bg = p.avatar_color || '#5b6b8c';
|
||
if (p.avatar_url) return '<span class="db-person-avatar" style="width:'+size+'px;height:'+size+'px;background-image:url('+escHtml(p.avatar_url)+');background-size:cover"></span>';
|
||
return '<span class="db-person-avatar" style="width:'+size+'px;height:'+size+'px;background:'+escHtml(bg)+'">'+escHtml(initials(p.login||p.full_name))+'</span>';
|
||
}
|
||
function displayValue(prop, val) {
|
||
if (val === undefined || val === null || val === '') return '<span class="db-cell-value">\u2014</span>';
|
||
var t = prop.prop_type;
|
||
if (t === 'checkbox') return '<span class="db-cell-value">'+(val ? '✓' : '')+'</span>';
|
||
if (t === 'person' && Array.isArray(val)) {
|
||
if (!val.length) return '<span class="db-cell-value">\u2014</span>';
|
||
return val.map(function(p){ return '<span class="db-person-chip">'+avatarHtml(p)+escHtml(p.login||p.full_name||'')+'</span>'; }).join('');
|
||
}
|
||
if ((t === 'select' || t === 'status')) {
|
||
return '<span class="db-chip"><span class="db-chip-dot" style="background:'+optColor(prop,val)+'"></span>'+escHtml(val)+'</span>';
|
||
}
|
||
if (t === 'multi_select' && Array.isArray(val)) {
|
||
return val.map(function(v){ return '<span class="db-chip"><span class="db-chip-dot" style="background:'+optColor(prop,v)+'"></span>'+escHtml(v)+'</span>'; }).join('') || '<span class="db-cell-value">\u2014</span>';
|
||
}
|
||
if (t === 'date') {
|
||
return '<span class="db-cell-value">'+escHtml(String(val).slice(0,10))+'</span>';
|
||
}
|
||
if (t === 'created_time' || t === 'last_edited_time') {
|
||
return '<span class="db-cell-value db-cell-readonly">'+escHtml(String(val).slice(0,16).replace('T',' '))+'</span>';
|
||
}
|
||
if (t === 'created_by' || t === 'last_edited_by') {
|
||
return '<span class="db-person-chip">'+avatarHtml(val)+escHtml(val.login||val.full_name||'')+'</span>';
|
||
}
|
||
if (t === 'url' && val) return '<span class="db-cell-value"><a href="'+escHtml(val)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">'+escHtml(val)+'</a></span>';
|
||
if (t === 'email' && val) return '<span class="db-cell-value"><a href="mailto:'+escHtml(val)+'" onclick="event.stopPropagation()">'+escHtml(val)+'</a></span>';
|
||
if (t === 'relation' && Array.isArray(val)) return '<span class="db-cell-value">'+escHtml(val.join(', '))+'</span>';
|
||
return '<span class="db-cell-value">'+escHtml(Array.isArray(val)?val.join(', '):String(val))+'</span>';
|
||
}
|
||
function textValue(prop, val) {
|
||
if (val === undefined || val === null) return '';
|
||
if (Array.isArray(val)) return val.map(function(v){ return (v && (v.login||v.full_name)) || v; }).join(', ');
|
||
return String(val);
|
||
}
|
||
|
||
// ── DB Instance (one per container) ──
|
||
function DBInstance(containerEl, collectionId, initialData) {
|
||
var state = {
|
||
collection: (initialData && initialData.collection) || {},
|
||
properties: (initialData && initialData.properties) || [],
|
||
pages: (initialData && initialData.pages) || [],
|
||
views: (initialData && initialData.views) || [],
|
||
members: null,
|
||
activeViewId: null,
|
||
viewConfig: {},
|
||
viewType: 'table',
|
||
editingCell: null,
|
||
colWidths: {},
|
||
collapsedGroups: {},
|
||
calCursor: new Date(),
|
||
zones: null
|
||
};
|
||
|
||
function propsById() { var m = {}; state.properties.forEach(function(p){ m[String(p.id)] = p; }); return m; }
|
||
function findProp(ref) {
|
||
if (ref === undefined || ref === null || ref === '') return null;
|
||
return state.properties.filter(function(p){ return String(p.id) === String(ref) || p.name === ref; })[0] || null;
|
||
}
|
||
function firstPropOfTypes(types) {
|
||
return state.properties.filter(function(p){ return types.indexOf(p.prop_type) >= 0; })[0] || null;
|
||
}
|
||
function pageById(id) { return state.pages.filter(function(p){ return p.id === id; })[0]; }
|
||
function groupProp() {
|
||
return findProp(state.viewConfig.group_by) || firstPropOfTypes(['status','select']) || state.properties[0];
|
||
}
|
||
|
||
function activeView() {
|
||
return state.views.filter(function(v){ return v.id === state.activeViewId; })[0] || null;
|
||
}
|
||
function applyView(v) {
|
||
if (!v) { state.activeViewId = null; state.viewType = 'table'; state.viewConfig = {}; return; }
|
||
state.activeViewId = v.id;
|
||
state.viewType = v.view_type || 'table';
|
||
state.viewConfig = parseJSON(v.config_json, {}) || {};
|
||
}
|
||
function ensureView() {
|
||
if (state.views.length) { applyView(state.views[0]); return Promise.resolve(); }
|
||
return fetch('/db/'+collectionId+'/views/save-as', {
|
||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||
body: JSON.stringify({name:'Table', view_type:'table', config:{}})
|
||
}).then(function(r){ return r.json(); }).then(function(d){
|
||
state.views = [d]; applyView(d);
|
||
}).catch(function(){ applyView(null); });
|
||
}
|
||
|
||
function persistConfig(patch, opts) {
|
||
opts = opts || {};
|
||
Object.keys(patch).forEach(function(k){ state.viewConfig[k] = patch[k]; });
|
||
var v = activeView();
|
||
if (!v) {
|
||
return fetch('/db/'+collectionId+'/views/save-as', {
|
||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||
body: JSON.stringify({name: state.viewType.charAt(0).toUpperCase()+state.viewType.slice(1), view_type: state.viewType, config: state.viewConfig})
|
||
}).then(function(r){ return r.json(); }).then(function(d){ state.views.push(d); applyView(d); render(); });
|
||
}
|
||
if (opts.name) v.name = opts.name;
|
||
if (opts.viewType) { v.view_type = opts.viewType; state.viewType = opts.viewType; }
|
||
v.config_json = JSON.stringify(state.viewConfig);
|
||
if (!opts.silent) render();
|
||
return fetch('/db/views/'+v.id+'/config', {
|
||
method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||
body: JSON.stringify(Object.assign({}, patch, opts.name?{name:opts.name}:{}, opts.viewType?{view_type:opts.viewType}:{}))
|
||
}).catch(function(){});
|
||
}
|
||
|
||
// ── Root render ──
|
||
function render() {
|
||
state.properties.forEach(function(p){ if (!state.colWidths[p.id]) state.colWidths[p.id] = 200; });
|
||
var h = renderViewBar();
|
||
if (state.viewType === 'board') h += renderBoard();
|
||
else if (state.viewType === 'calendar') h += renderCalendar();
|
||
else if (state.viewType === 'gallery') h += renderGallery();
|
||
else if (state.viewType === 'list') h += renderList();
|
||
else h += renderTable();
|
||
containerEl.innerHTML = h;
|
||
bindEvents();
|
||
}
|
||
|
||
var VIEW_ICONS = {
|
||
table:'<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||
board:'<rect x="3" y="3" width="5" height="18"/><rect x="10" y="3" width="5" height="12"/><rect x="17" y="3" width="4" height="16"/>',
|
||
calendar:'<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||
gallery:'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>',
|
||
list:'<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1"/><circle cx="3.5" cy="12" r="1"/><circle cx="3.5" cy="18" r="1"/>'
|
||
};
|
||
function viewSvg(t) {
|
||
return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+(VIEW_ICONS[t]||VIEW_ICONS.table)+'</svg>';
|
||
}
|
||
|
||
function renderViewBar() {
|
||
var h = '<div class="db-view-bar" style="padding:4px 0 8px"><div class="db-view-tabs">';
|
||
if (!state.views.length) {
|
||
h += '<button class="db-view-tab active" data-view-id="">'+viewSvg(state.viewType)+' '+state.viewType.charAt(0).toUpperCase()+state.viewType.slice(1)+'</button>';
|
||
}
|
||
state.views.forEach(function(v){
|
||
var active = v.id === state.activeViewId;
|
||
h += '<button class="db-view-tab'+(active?' active':'')+'" data-view-id="'+v.id+'">'+viewSvg(v.view_type)+' '+escHtml(v.name)+
|
||
(active ? '<span class="db-view-settings" data-view-settings="1" title="View settings">⚙</span>' : '')+
|
||
'<span class="db-view-x" data-view-delete="'+v.id+'" title="Delete view">✕</span></button>';
|
||
});
|
||
h += '<button class="db-view-add" data-view-add="1" title="Add a view">+</button>';
|
||
h += '</div>';
|
||
h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>';
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
|
||
function bindViewBar() {
|
||
containerEl.querySelectorAll('[data-view-id]').forEach(function(b){
|
||
b.onclick = function(e){
|
||
if (e.target.closest('[data-view-delete]') || e.target.closest('[data-view-settings]')) return;
|
||
var id = this.dataset.viewId; if (!id) return;
|
||
var v = state.views.filter(function(x){ return String(x.id) === String(id); })[0];
|
||
if (v) { applyView(v); render(); }
|
||
};
|
||
});
|
||
containerEl.querySelectorAll('[data-view-delete]').forEach(function(b){
|
||
b.onclick = function(e){
|
||
e.stopPropagation();
|
||
var id = parseInt(this.dataset.viewDelete);
|
||
if (!confirm('Delete this view?')) return;
|
||
fetch('/db/views/'+id+'/api', {method:'DELETE', headers:{'X-CSRF-Token':getCsrf()}}).then(function(){
|
||
state.views = state.views.filter(function(v){ return v.id !== id; });
|
||
if (state.activeViewId === id) applyView(state.views[0] || null);
|
||
render();
|
||
});
|
||
};
|
||
});
|
||
var settings = containerEl.querySelector('[data-view-settings]');
|
||
if (settings) settings.onclick = function(e){ e.stopPropagation(); openViewSettings(this); };
|
||
var add = containerEl.querySelector('[data-view-add]');
|
||
if (add) add.onclick = function(e){ e.stopPropagation(); openAddViewMenu(this); };
|
||
containerEl.querySelectorAll('.db-add-page-btn').forEach(function(b){ b.onclick = addNewPage; });
|
||
}
|
||
|
||
// ── Table view ──
|
||
function groupedProperties() {
|
||
var groups = [];
|
||
state.properties.forEach(function(p){
|
||
var g = p.group_name || '';
|
||
var last = groups[groups.length-1];
|
||
if (!last || last.name !== g) { last = {name:g, props:[]}; groups.push(last); }
|
||
last.props.push(p);
|
||
});
|
||
return groups;
|
||
}
|
||
function renderTable() {
|
||
var props = state.properties;
|
||
var pages = state.pages;
|
||
var groups = groupedProperties();
|
||
var hasGroups = groups.some(function(g){ return g.name; });
|
||
var h = '<div class="db-table-wrapper"><table class="db-table">';
|
||
h += '<thead>';
|
||
if (hasGroups) {
|
||
h += '<tr class="db-group-row">';
|
||
h += '<th style="width:32px;min-width:32px"></th>';
|
||
groups.forEach(function(g, gi){
|
||
var collapsed = state.collapsedGroups[gi];
|
||
h += '<th colspan="'+g.props.length+'" data-group-header="'+gi+'">';
|
||
if (g.name) {
|
||
h += '<span class="db-group-toggle'+(collapsed?' collapsed':'')+'" data-group-toggle="'+gi+'"><span class="chev">▾</span>'+escHtml(g.name)+'</span>';
|
||
}
|
||
h += '</th>';
|
||
});
|
||
h += '<th></th><th style="width:72px;min-width:72px"></th>';
|
||
h += '</tr>';
|
||
}
|
||
h += '<tr class="db-header-row">';
|
||
h += '<th class="db-th db-th-handle" style="width:32px;min-width:32px"></th>';
|
||
props.forEach(function(prop){
|
||
var w = state.colWidths[prop.id] || 200;
|
||
var groupIdx = 0;
|
||
groups.forEach(function(g, gi){ if (g.props.indexOf(prop) >= 0) groupIdx = gi; });
|
||
var collapsed = hasGroups && state.collapsedGroups[groupIdx];
|
||
h += '<th class="db-th db-th-property" data-colgroup="'+groupIdx+'" style="'+(collapsed?'display:none;':'')+'min-width:'+w+'px;width:'+w+'px" data-prop-id="'+prop.id+'">';
|
||
h += '<div class="db-th-content" data-prop-id="'+prop.id+'" data-prop-type="'+prop.prop_type+'" data-prop-name="'+escHtml(prop.name)+'">';
|
||
h += '<span class="db-prop-type-icon">'+propTypeSvg(prop.prop_type)+'</span>';
|
||
h += '<span class="db-prop-name db-col-title-btn">'+escHtml(prop.name)+'</span>';
|
||
h += '</div><div class="db-col-resize-handle" data-prop-id="'+prop.id+'"></div></th>';
|
||
});
|
||
h += '<th class="db-th db-th-add-prop"><button class="db-add-prop-btn db-show-prop-modal-btn">+ Add property</button></th>';
|
||
h += '<th class="db-th" style="width:72px;min-width:72px"></th>';
|
||
h += '</tr></thead><tbody>';
|
||
pages.forEach(function(cp){
|
||
h += '<tr class="db-row" data-page-id="'+cp.id+'">';
|
||
h += '<td class="db-td db-td-handle"><span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span></td>';
|
||
props.forEach(function(prop){
|
||
var groupIdx = 0;
|
||
groups.forEach(function(g, gi){ if (g.props.indexOf(prop) >= 0) groupIdx = gi; });
|
||
var collapsed = hasGroups && state.collapsedGroups[groupIdx];
|
||
var val = pageVal(cp, prop);
|
||
var ro = READONLY_TYPES.indexOf(prop.prop_type) >= 0;
|
||
h += '<td class="db-td db-td-cell" data-colgroup="'+groupIdx+'" style="'+(collapsed?'display:none;':'')+'min-width:'+(state.colWidths[prop.id]||200)+'px">';
|
||
h += '<div class="db-cell-content'+(ro?' db-cell-ro':'')+'" data-page-id="'+cp.id+'" data-prop-id="'+prop.id+'">';
|
||
h += displayValue(prop, val);
|
||
if (prop.prop_type === 'date') h += recBadgeHtml(cp);
|
||
if (prop.prop_type === 'title') h += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+cp.id+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
|
||
h += '</div></td>';
|
||
});
|
||
h += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden;white-space:nowrap"><button class="db-ai-btn db-ai-fill-btn" data-page-id="'+cp.id+'" title="Remplir les propriétés avec l\'IA" style="background:none;border:none;cursor:pointer;font-size:14px;padding:2px 4px;color:var(--text-dim)">✨</button><button class="db-open-btn db-open-page-btn" data-page-id="'+cp.id+'" title="Open">↗</button></td>';
|
||
h += '</tr>';
|
||
});
|
||
var emptyNeeded = Math.max(0, 4 - pages.length);
|
||
for (var i=0;i<emptyNeeded;i++){
|
||
h += '<tr class="db-row db-row-empty"><td class="db-td db-td-handle"></td>';
|
||
props.forEach(function(){ h += '<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>'; });
|
||
h += '<td class="db-td db-td-open"></td></tr>';
|
||
}
|
||
h += '<tr class="db-row db-row-new"><td class="db-td db-td-handle"><span class="db-plus-icon">+</span></td>';
|
||
h += '<td class="db-td db-td-name" colspan="'+(Math.max(1,props.length)+1)+'"><button class="db-new-page-btn db-add-page-btn">New page</button></td></tr>';
|
||
h += '</tbody></table></div>';
|
||
return h;
|
||
}
|
||
|
||
// ── Board view ──
|
||
function optionColumns(prop) {
|
||
if (!prop) return [{key:'', label:'Items'}];
|
||
if (prop.prop_type === 'person') {
|
||
var cols = (state.members||[]).map(function(m){ return {key:m.login, label:m.full_name||m.login, person:m}; });
|
||
cols.push({key:'__none__', label:'No assignee'});
|
||
return cols;
|
||
}
|
||
var opts = parseOpts(prop).map(function(o){ return {key:o.name, label:o.name, color:o.color}; });
|
||
if (!opts.length) {
|
||
var seen = {};
|
||
state.pages.forEach(function(cp){
|
||
var v = pageVal(cp, prop); if (v === undefined || v === null || v === '') return;
|
||
(Array.isArray(v)?v:[v]).forEach(function(x){ if (!seen[x]) { seen[x]=1; opts.push({key:x,label:x}); } });
|
||
});
|
||
opts.push({key:'__none__', label:'No value'});
|
||
} else {
|
||
opts.push({key:'__none__', label:'No value'});
|
||
}
|
||
return opts;
|
||
}
|
||
function columnKeyFor(prop, cp) {
|
||
var v = pageVal(cp, prop);
|
||
if (prop.prop_type === 'person') {
|
||
if (Array.isArray(v) && v.length) return v[0].login || v[0].full_name || '__none__';
|
||
return '__none__';
|
||
}
|
||
if (Array.isArray(v)) return v[0] || '__none__';
|
||
if (v === undefined || v === null || v === '') return '__none__';
|
||
return String(v);
|
||
}
|
||
function cardHtml(cp, opts) {
|
||
opts = opts || {};
|
||
var cover = opts.cover !== false ? coverHtml(cp) : '';
|
||
var size = state.viewConfig.card_size || 'small';
|
||
var cardProps = state.viewConfig.card_properties;
|
||
if (!Array.isArray(cardProps) || !cardProps.length) cardProps = defaultCardProps();
|
||
var body = '<div class="db-card-body">';
|
||
body += '<div class="db-card-title">'+(cp.icon&&cp.icon!=='file'?escHtml(cp.icon)+' ':'')+escHtml(cp.title||'Untitled')+'</div>';
|
||
cardProps.forEach(function(ref){
|
||
var p = findProp(ref); if (!p) return;
|
||
var v = pageVal(cp, p);
|
||
var txt = textValue(p, v);
|
||
if (!txt) return;
|
||
body += '<div class="db-card-prop"><span class="k">'+escHtml(p.name)+'</span><span>'+escHtml(txt.slice(0,60))+'</span></div>';
|
||
});
|
||
body += '</div>';
|
||
return '<div class="db-card '+(size==='small'?'compact':'')+'" draggable="true" data-card-page="'+cp.id+'">'+cover+body+'</div>';
|
||
}
|
||
function defaultCardProps() {
|
||
return state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3).map(function(p){ return p.id; });
|
||
}
|
||
function coverHtml(cp) {
|
||
if (cp.cover_url) return '<div class="db-card-cover" style="background-image:url('+escHtml(cp.cover_url)+')"></div>';
|
||
var mode = state.viewConfig.cover_mode || 'icon';
|
||
if (mode === 'none') return '';
|
||
if (mode === 'property') {
|
||
var p = findProp(state.viewConfig.cover_property);
|
||
if (p) {
|
||
var v = pageVal(cp, p);
|
||
var url = null;
|
||
if (Array.isArray(v) && v.length) url = (v[0] && (v[0].url||v[0].src)) || (typeof v[0]==='string'?v[0]:null);
|
||
else if (typeof v === 'string' && /^https?:\/\//.test(v)) url = v;
|
||
if (url) return '<div class="db-card-cover" style="background-image:url('+escHtml(url)+')"></div>';
|
||
}
|
||
}
|
||
if (mode === 'color') {
|
||
var g = groupProp();
|
||
var key = g ? columnKeyFor(g, cp) : '';
|
||
var col = g ? optColor(g, key) : '#8b8b8b';
|
||
return '<div class="db-card-cover" style="background:'+col+'"></div>';
|
||
}
|
||
return '<div class="db-card-cover">'+escHtml(cp.icon && cp.icon!=='file' ? cp.icon : '📄')+'</div>';
|
||
}
|
||
function renderBoard() {
|
||
var g = groupProp();
|
||
if (!g) return '<div style="padding:24px;color:var(--text-dim)">Add a Select, Status or Person property to group the board.</div>';
|
||
var sub = findProp(state.viewConfig.sub_group_by);
|
||
var cols = optionColumns(g);
|
||
var wip = state.viewConfig.wip_limits || {};
|
||
var h = '<div class="db-board-wrap">';
|
||
if (sub) {
|
||
var lanes = optionColumns(sub);
|
||
lanes.forEach(function(lane){
|
||
var lanePages = state.pages.filter(function(cp){ return columnKeyFor(sub, cp) === lane.key; });
|
||
if (!lanePages.length && lane.key === '__none__') return;
|
||
h += '<div class="db-lane"><div class="db-lane-head">'+escHtml(lane.label)+' <span class="db-board-count">'+lanePages.length+'</span></div>';
|
||
h += boardColumnsHtml(g, cols, lanePages, wip);
|
||
h += '</div>';
|
||
});
|
||
} else {
|
||
h += boardColumnsHtml(g, cols, state.pages, wip);
|
||
}
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
function boardColumnsHtml(g, cols, pages, wip) {
|
||
var h = '<div class="db-board">';
|
||
cols.forEach(function(col){
|
||
var colPages = pages.filter(function(cp){ return columnKeyFor(g, cp) === col.key; });
|
||
var limit = wip[col.key];
|
||
var exceeded = limit && colPages.length > Number(limit);
|
||
var dot = col.person ? avatarHtml(col.person) : (col.color ? '<span class="db-chip-dot" style="background:'+(PALETTE[col.color]||'#8b8b8b')+'"></span>' : '');
|
||
h += '<div class="db-board-col'+(exceeded?' wip-exceeded':'')+'" data-col-key="'+escHtml(col.key)+'">';
|
||
h += '<div class="db-board-col-head">'+dot+'<span>'+escHtml(col.label)+'</span><span class="db-board-count">'+colPages.length+'</span>';
|
||
if (limit) h += '<span class="db-wip">WIP '+colPages.length+'/'+limit+'</span>';
|
||
h += '</div>';
|
||
h += '<div class="db-board-cards" data-drop-col="'+escHtml(col.key)+'">';
|
||
colPages.forEach(function(cp){ h += cardHtml(cp); });
|
||
h += '<button class="db-card-add" data-add-col="'+escHtml(col.key)+'">+ New</button>';
|
||
h += '</div></div>';
|
||
});
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
|
||
// ── Calendar view (v5.8.0: day/week/month + server-expanded recurrences) ──
|
||
function dateProp() { return findProp(state.viewConfig.date_property) || firstPropOfTypes(['date']); }
|
||
var CAL_DOW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
||
var MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||
function isoDay(d) { return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); }
|
||
function mondayOf(d) { var x = new Date(d); x.setDate(x.getDate() - ((x.getDay()+6)%7)); x.setHours(0,0,0,0); return x; }
|
||
function calMode() { var m = state.viewConfig.calendar_mode; return (m==='day'||m==='week') ? m : 'month'; }
|
||
|
||
function baseEvents() {
|
||
var dp = dateProp(); if (!dp) return {};
|
||
var byDate = {};
|
||
state.pages.forEach(function(cp){
|
||
var v = pageVal(cp, dp); if (!v) return;
|
||
var ds = String(v).slice(0,10);
|
||
(byDate[ds] = byDate[ds] || []).push({page_id:cp.id, title:cp.title, icon:cp.icon, recurring:false, time:String(v).length>10?String(v).slice(11,16):''});
|
||
});
|
||
return byDate;
|
||
}
|
||
function eventsForWindow(startIso, endIso) {
|
||
// Server expansion covers recurring occurrences; merge over base events.
|
||
var dp = dateProp();
|
||
if (!dp) return Promise.resolve({});
|
||
return fetch('/db/'+collectionId+'/calendar/api?start='+startIso+'&end='+endIso+'&date_property='+dp.id, {credentials:'same-origin'})
|
||
.then(function(r){ return r.ok ? r.json() : {events:[]}; })
|
||
.then(function(d){
|
||
var byDate = {};
|
||
(d.events||[]).forEach(function(ev){ (byDate[ev.date] = byDate[ev.date] || []).push(ev); });
|
||
return {byDate: byDate, timezone: d.timezone || ''};
|
||
})
|
||
.catch(function(){ return {byDate: baseEvents(), timezone:''}; });
|
||
}
|
||
function evChipHtml(ev) {
|
||
var badge = ev.recurring ? '<span class="db-ev-badge" title="Recurring">\u21bb</span>' : '';
|
||
return '<div class="db-cal-ev" draggable="true" data-card-page="'+ev.page_id+'" data-ev-date="'+escHtml(ev.date)+'">'
|
||
+ (ev.time ? '<span class="db-cal-ev-time">'+escHtml(ev.time)+'</span>' : '')
|
||
+ (ev.icon && ev.icon !== 'file' ? escHtml(ev.icon)+' ' : '')
|
||
+ escHtml(ev.title || 'Untitled') + badge + '</div>';
|
||
}
|
||
function renderCalendar() {
|
||
var dp = dateProp();
|
||
if (!dp) return '<div style="padding:24px;color:var(--text-dim)">Add a Date property to use the calendar view.</div>';
|
||
var mode = calMode();
|
||
var cur = state.calCursor;
|
||
var h = '<div class="db-cal-head">';
|
||
h += '<div class="db-cal-nav"><button data-cal-prev="1">\u2039</button><button data-cal-next="1">\u203a</button></div>';
|
||
var title;
|
||
if (mode === 'month') title = MONTH_NAMES[cur.getMonth()]+' '+cur.getFullYear();
|
||
else if (mode === 'week') { var ws=mondayOf(cur); title = MONTH_NAMES[ws.getMonth()].slice(0,3)+' '+ws.getDate()+' \u2013 '+(function(we){return MONTH_NAMES[we.getMonth()].slice(0,3)+' '+we.getDate();})(new Date(ws.getTime()+6*864e5)); }
|
||
else title = MONTH_NAMES[cur.getMonth()]+' '+cur.getDate()+', '+cur.getFullYear();
|
||
h += '<div class="db-cal-title">'+title+'</div>';
|
||
h += '<button class="db-btn-new" data-cal-today="1" style="padding:3px 10px">Today</button>';
|
||
h += '<div class="db-cal-modes">'+
|
||
['day','week','month'].map(function(m){ return '<button class="db-cal-mode'+(m===mode?' active':'')+'" data-cal-mode="'+m+'">'+m.charAt(0).toUpperCase()+m.slice(1)+'</button>'; }).join('')+
|
||
'</div>';
|
||
h += '</div>';
|
||
h += '<div id="db-cal-body" data-cal-loading="1"><div style="padding:24px;color:var(--text-dim)">Loading\u2026</div></div>';
|
||
return h;
|
||
}
|
||
function calWindow() {
|
||
var mode = calMode(); var cur = state.calCursor;
|
||
if (mode === 'month') {
|
||
var first = new Date(cur.getFullYear(), cur.getMonth(), 1);
|
||
var startDow = (first.getDay()+6)%7;
|
||
var start = new Date(first.getTime() - startDow*864e5);
|
||
var daysInMonth = new Date(cur.getFullYear(), cur.getMonth()+1, 0).getDate();
|
||
var totalCells = Math.ceil((startDow + daysInMonth)/7)*7;
|
||
return {start: start, days: totalCells, mode: mode, startDow: startDow, daysInMonth: daysInMonth};
|
||
}
|
||
if (mode === 'week') return {start: mondayOf(cur), days: 7, mode: mode};
|
||
return {start: new Date(cur.getFullYear(), cur.getMonth(), cur.getDate()), days: 1, mode: mode};
|
||
}
|
||
function paintCalendar(win, data) {
|
||
var body = containerEl.querySelector('#db-cal-body');
|
||
if (!body) return;
|
||
body.removeAttribute('data-cal-loading');
|
||
var today = new Date(); var todayIso = isoDay(today);
|
||
var byDate = data.byDate || {};
|
||
var h = '';
|
||
if (win.mode === 'month') {
|
||
h += '<div class="db-cal-grid">';
|
||
CAL_DOW.forEach(function(d){ h += '<div class="db-cal-dow">'+d+'</div>'; });
|
||
for (var i=0;i<win.days;i++) {
|
||
var cellDate = new Date(win.start.getTime() + i*864e5);
|
||
var iso = isoDay(cellDate);
|
||
var other = cellDate.getMonth() !== state.calCursor.getMonth();
|
||
var isToday = iso === todayIso;
|
||
h += '<div class="db-cal-day'+(other?' other':'')+(isToday?' today':'')+'" data-cal-date="'+iso+'">';
|
||
h += '<div class="db-cal-daynum">'+cellDate.getDate()+'</div>';
|
||
(byDate[iso]||[]).forEach(function(ev){ h += evChipHtml(ev); });
|
||
h += '</div>';
|
||
}
|
||
h += '</div>';
|
||
} else if (win.mode === 'week') {
|
||
h += '<div class="db-cal-grid db-cal-week">';
|
||
CAL_DOW.forEach(function(d){ h += '<div class="db-cal-dow">'+d+'</div>'; });
|
||
for (var j=0;j<7;j++) {
|
||
var d2 = new Date(win.start.getTime() + j*864e5);
|
||
var iso2 = isoDay(d2);
|
||
h += '<div class="db-cal-day'+(iso2===todayIso?' today':'')+'" data-cal-date="'+iso2+'" style="min-height:180px">';
|
||
h += '<div class="db-cal-daynum">'+d2.getDate()+'</div>';
|
||
(byDate[iso2]||[]).forEach(function(ev){ h += evChipHtml(ev); });
|
||
h += '</div>';
|
||
}
|
||
h += '</div>';
|
||
} else {
|
||
var iso3 = isoDay(win.start);
|
||
h += '<div class="db-cal-day-col" data-cal-date="'+iso3+'">';
|
||
h += '<div class="db-cal-dayhead">'+CAL_DOW[(win.start.getDay()+6)%7]+' '+MONTH_NAMES[win.start.getMonth()]+' '+win.start.getDate()+(iso3===todayIso?' \u00b7 today':'')+'</div>';
|
||
(byDate[iso3]||[]).slice().sort(function(a,b){ return (a.time||'99').localeCompare(b.time||'99'); }).forEach(function(ev){
|
||
h += '<div class="db-cal-agenda-row" draggable="true" data-card-page="'+ev.page_id+'" data-ev-date="'+iso3+'">'
|
||
+ '<span class="db-cal-ev-time">'+(ev.time || '\u2014')+'</span>'
|
||
+ '<span>'+(ev.icon && ev.icon!=='file' ? escHtml(ev.icon)+' ' : '')+escHtml(ev.title||'Untitled')+'</span>'
|
||
+ (ev.recurring ? '<span class="db-ev-badge">\u21bb</span>' : '')
|
||
+ '</div>';
|
||
});
|
||
if (!(byDate[iso3]||[]).length) h += '<div style="padding:16px;color:var(--text-dim);font-size:13px">No events</div>';
|
||
h += '</div>';
|
||
}
|
||
body.innerHTML = h;
|
||
bindCalendarCells();
|
||
}
|
||
var calSeq = 0;
|
||
function refreshCalendar() {
|
||
var win = calWindow();
|
||
var startIso = isoDay(win.start);
|
||
var endIso = isoDay(new Date(win.start.getTime() + (win.days-1)*864e5));
|
||
var seq = ++calSeq;
|
||
eventsForWindow(startIso, endIso).then(function(data){
|
||
if (seq !== calSeq) return; // a newer request won
|
||
paintCalendar(win, data);
|
||
});
|
||
}
|
||
function bindCalendarCells() {
|
||
// day cells: drop to reschedule
|
||
containerEl.querySelectorAll('[data-cal-date]').forEach(function(day){
|
||
day.addEventListener('dragover', function(e){ e.preventDefault(); this.classList.add('drop-target'); });
|
||
day.addEventListener('dragleave', function(){ this.classList.remove('drop-target'); });
|
||
day.addEventListener('drop', function(e){
|
||
e.preventDefault(); this.classList.remove('drop-target');
|
||
var pid = parseInt(e.dataTransfer.getData('text/plain'));
|
||
var cp = pageById(pid); var dp = dateProp();
|
||
if (!cp || !dp) return;
|
||
var old = pageVal(cp, dp);
|
||
var newDate = this.dataset.calDate;
|
||
var time = (old && String(old).length > 10) ? String(old).slice(10) : '';
|
||
setPageVal(cp, dp, newDate + time); render();
|
||
savePage(cp);
|
||
});
|
||
});
|
||
// event chips: open row in peek (stop drag click-through)
|
||
containerEl.querySelectorAll('.db-cal-ev, .db-cal-agenda-row').forEach(function(ev){
|
||
ev.addEventListener('dragstart', function(e){
|
||
e.dataTransfer.setData('text/plain', this.dataset.cardPage);
|
||
this.classList.add('dragging');
|
||
});
|
||
ev.addEventListener('dragend', function(){ this.classList.remove('dragging'); });
|
||
ev.addEventListener('click', function(e){
|
||
e.stopPropagation();
|
||
openPageInSidePeek(parseInt(this.dataset.cardPage));
|
||
});
|
||
ev.addEventListener('dblclick', function(e){
|
||
e.stopPropagation();
|
||
openEventEditor(parseInt(this.dataset.cardPage), this.dataset.evDate, e);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── v5.8.0: event editor popover (Repeat + Remind + time) ──
|
||
function openEventEditor(pageId, evDate, mouseEvt) {
|
||
var cp = pageById(pageId); var dp = dateProp();
|
||
if (!cp || !dp) return;
|
||
if (!state.zones) {
|
||
fetch('/db/timezones/api', {credentials:'same-origin'}).then(function(r){return r.ok?r.json():{zones:[]};})
|
||
.then(function(d){ state.zones = d.zones || []; }).catch(function(){});
|
||
}
|
||
var old = document.querySelector('.db-event-pop'); if (old) old.remove();
|
||
var pid = String(dp.id);
|
||
var rec = (metaOf(cp, '__recurrence__') || {})[pid] || null;
|
||
var rem = (metaOf(cp, '__reminder__') || {})[pid] || null;
|
||
var baseVal = String(pageVal(cp, dp) || '');
|
||
var curTime = baseVal.length >= 16 ? baseVal.slice(11,16) : '';
|
||
var pop = document.createElement('div');
|
||
pop.className = 'db-event-pop';
|
||
var freq = rec ? rec.freq : '';
|
||
var curTz = ((metaOf(cp, '__timezone__') || {})[pid]) || '';
|
||
var h = '<div class="pop-title">'+escHtml(cp.title||'Untitled')+'</div>';
|
||
h += '<div class="pop-row"><label>Time</label><input type="time" data-ev-time value="'+escHtml(curTime)+'"></div>';
|
||
h += '<div class="pop-row"><label>Timezone</label><select data-ev-tz><option value="">Default (yours/UTC)</option>';
|
||
(state.zones||[]).forEach(function(z){
|
||
h += '<option value="'+escHtml(z)+'"'+(curTz===z?' selected':'')+'>'+escHtml(z)+'</option>';
|
||
});
|
||
h += '</select></div>';
|
||
h += '<div class="pop-row"><label>Repeat</label><select data-ev-freq>';
|
||
[['','Does not repeat'],['daily','Daily'],['weekly','Weekly'],['monthly','Monthly']].forEach(function(o){
|
||
h += '<option value="'+o[0]+'"'+(freq===o[0]?' selected':'')+'>'+o[1]+'</option>';
|
||
});
|
||
h += '</select></div>';
|
||
if (freq) {
|
||
h += '<div class="pop-row"><label>Every</label><input type="number" min="1" max="365" data-ev-interval value="'+(rec.interval||1)+'" style="width:60px"></div>';
|
||
h += '<div class="pop-row"><label>Ends</label><select data-ev-endmode>';
|
||
h += '<option value="never"'+(rec.count==null&&!rec.until?' selected':'')+'>Never</option>';
|
||
h += '<option value="count"'+(rec.count!=null?' selected':'')+'>After…</option>';
|
||
h += '<option value="until"'+(rec.until?' selected':'')+'>On date…</option>';
|
||
h += '</select></div>';
|
||
if (rec.count != null) h += '<div class="pop-row"><label>occurrences</label><input type="number" min="1" max="1000" data-ev-count value="'+rec.count+'" style="width:70px"></div>';
|
||
if (rec.until) h += '<div class="pop-row"><label>until</label><input type="date" data-ev-until value="'+escHtml(String(rec.until).slice(0,10))+'"></div>';
|
||
}
|
||
h += '<div class="pop-sep"></div>';
|
||
h += '<div class="pop-row"><label>Remind</label><select data-ev-remunit>';
|
||
[['none','None'],['minutes','minutes before'],['hours','hours before'],['days','days before']].forEach(function(o){
|
||
var u = rem && rem.unit ? rem.unit : 'none';
|
||
h += '<option value="'+o[0]+'"'+(u===o[0]?' selected':'')+'>'+o[1]+'</option>';
|
||
});
|
||
h += '</select><input type="number" min="1" data-ev-remval value="'+(rem&&rem.value?rem.value:30)+'" style="width:60px;margin-left:6px"></div>';
|
||
h += '<div style="text-align:right;padding-top:8px"><button class="db-btn-new" data-ev-save style="padding:4px 14px">Save</button></div>';
|
||
pop.innerHTML = h;
|
||
document.body.appendChild(pop);
|
||
if (mouseEvt) {
|
||
pop.style.left = Math.min(mouseEvt.clientX, window.innerWidth-300)+'px';
|
||
pop.style.top = Math.min(mouseEvt.clientY, window.innerHeight-320)+'px';
|
||
} else { pop.style.left='50%'; pop.style.top='120px'; }
|
||
var freqSel = pop.querySelector('[data-ev-freq]');
|
||
function collectDraft() {
|
||
var draft = rec ? Object.assign({}, rec) : {};
|
||
draft.freq = freqSel.value;
|
||
var iv = pop.querySelector('[data-ev-interval]');
|
||
if (iv) draft.interval = parseInt(iv.value)||1;
|
||
if (!pop.querySelector('[data-ev-interval]')) draft.interval = draft.interval || 1;
|
||
var em = pop.querySelector('[data-ev-endmode]');
|
||
if (em) {
|
||
if (em.value === 'count') { var c=pop.querySelector('[data-ev-count]'); draft.count = c ? (parseInt(c.value)||null) : null; draft.until = null; }
|
||
else if (em.value === 'until') { var u=pop.querySelector('[data-ev-until]'); draft.until = (u&&u.value) ? u.value : null; draft.count = null; }
|
||
else { draft.count = null; draft.until = null; }
|
||
}
|
||
if (!draft.freq) return null;
|
||
return draft;
|
||
}
|
||
freqSel.onchange = function(){
|
||
// re-open to reveal/hide detail rows, keeping the in-place edits
|
||
setMeta(cp, '__recurrence__', dp.id, collectDraft());
|
||
rec = (metaOf(cp, '__recurrence__') || {})[pid] || null;
|
||
pop.remove();
|
||
openEventEditor(pageId, evDate, mouseEvt);
|
||
};
|
||
var endSel = pop.querySelector('[data-ev-endmode]');
|
||
if (endSel) endSel.onchange = function(){
|
||
setMeta(cp, '__recurrence__', dp.id, collectDraft());
|
||
rec = (metaOf(cp, '__recurrence__') || {})[pid] || null;
|
||
pop.remove();
|
||
openEventEditor(pageId, evDate, mouseEvt);
|
||
};
|
||
pop.querySelector('[data-ev-save]').onclick = function(){
|
||
var base = (baseVal.slice(0,10)) || evDate || isoDay(new Date());
|
||
var t = pop.querySelector('[data-ev-time]').value;
|
||
var recDraft = collectDraft();
|
||
var tzVal = pop.querySelector('[data-ev-tz]').value;
|
||
var unit = pop.querySelector('[data-ev-remunit]').value;
|
||
var val = parseInt(pop.querySelector('[data-ev-remval]').value)||0;
|
||
setPageVal(cp, dp, t && base ? (base+'T'+t) : base);
|
||
setMeta(cp, '__recurrence__', dp.id, recDraft);
|
||
setMeta(cp, '__timezone__', dp.id, tzVal || null);
|
||
setMeta(cp, '__reminder__', dp.id, unit==='none'?null:{value:val||1, unit:unit});
|
||
pop.remove(); render(); savePage(cp);
|
||
};
|
||
setTimeout(function(){
|
||
document.addEventListener('click', function onDoc(e){ if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
|
||
}, 0);
|
||
}
|
||
|
||
// ── Gallery view ──
|
||
function renderGallery() {
|
||
var size = state.viewConfig.card_size || 'medium';
|
||
var h = '<div class="db-gallery '+size+'">';
|
||
state.pages.forEach(function(cp){ h += cardHtml(cp); });
|
||
h += '<div class="db-card db-card-add-tile" data-gallery-add="1" style="display:flex;align-items:center;justify-content:center;min-height:120px;border:1px dashed var(--border);cursor:pointer;color:var(--text-dim)">+ New</div>';
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
|
||
// ── List view ──
|
||
function renderList() {
|
||
var cols = state.viewConfig.visible_properties;
|
||
var props;
|
||
if (Array.isArray(cols) && cols.length) props = cols.map(findProp).filter(Boolean);
|
||
else props = state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3);
|
||
var h = '<div class="db-list">';
|
||
state.pages.forEach(function(cp){
|
||
h += '<div class="db-list-row" data-card-page="'+cp.id+'">';
|
||
h += '<span class="db-list-icon">'+escHtml(cp.icon&&cp.icon!=='file'?cp.icon:'📄')+'</span>';
|
||
h += '<span class="db-list-title">'+escHtml(cp.title||'Untitled')+'</span>';
|
||
props.forEach(function(p){ h += '<span class="db-list-cell">'+escHtml(textValue(p, pageVal(cp,p)).slice(0,40))+'</span>'; });
|
||
h += '</div>';
|
||
});
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
|
||
// ── Event binding ──
|
||
function bindEvents() {
|
||
bindViewBar();
|
||
containerEl.querySelectorAll('.db-cell-content').forEach(function(el){
|
||
el.onclick = function(e){
|
||
if (state.editingCell || e.target.closest('.db-open-btn') || e.target.closest('a')) return;
|
||
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
|
||
};
|
||
});
|
||
containerEl.querySelectorAll('.db-open-page-btn').forEach(function(btn){
|
||
btn.onclick = function(e){ e.stopPropagation(); openPageInSidePeek(parseInt(this.dataset.pageId)); };
|
||
});
|
||
containerEl.querySelectorAll('.db-ai-fill-btn').forEach(function(btn){
|
||
btn.onclick = function(e){ e.stopPropagation(); aiFillRow(parseInt(this.dataset.pageId)); };
|
||
});
|
||
containerEl.querySelectorAll('.db-row').forEach(function(row){
|
||
row.onmouseenter = function(){ this.querySelectorAll('.db-hover-only').forEach(function(el){ el.style.visibility='visible'; }); };
|
||
row.onmouseleave = function(){ this.querySelectorAll('.db-hover-only').forEach(function(el){ el.style.visibility='hidden'; }); };
|
||
});
|
||
containerEl.querySelectorAll('.db-th-content').forEach(function(el){
|
||
el.onclick = function(e){ e.stopPropagation(); showColMenu(parseInt(this.dataset.propId), this.dataset.propType, this.dataset.propName, this); };
|
||
});
|
||
containerEl.querySelectorAll('.db-col-resize-handle').forEach(bindResize);
|
||
var addProp = containerEl.querySelector('.db-show-prop-modal-btn');
|
||
if (addProp) addProp.onclick = showPropModal;
|
||
// property group collapse
|
||
containerEl.querySelectorAll('[data-group-toggle]').forEach(function(el){
|
||
el.onclick = function(){
|
||
var gi = parseInt(this.dataset.groupToggle);
|
||
state.collapsedGroups[gi] = !state.collapsedGroups[gi];
|
||
render();
|
||
};
|
||
});
|
||
// open cards / rows
|
||
containerEl.querySelectorAll('[data-card-page]').forEach(function(el){
|
||
el.addEventListener('click', function(e){
|
||
if (e.target.closest('[data-add-col]')) return;
|
||
openPageInSidePeek(parseInt(this.dataset.cardPage));
|
||
});
|
||
el.addEventListener('dragstart', function(e){
|
||
e.dataTransfer.setData('text/plain', this.dataset.cardPage);
|
||
this.classList.add('dragging');
|
||
});
|
||
el.addEventListener('dragend', function(){ this.classList.remove('dragging'); });
|
||
});
|
||
// board drop targets
|
||
containerEl.querySelectorAll('[data-drop-col]').forEach(function(zone){
|
||
zone.addEventListener('dragover', function(e){ e.preventDefault(); this.classList.add('drop-target'); });
|
||
zone.addEventListener('dragleave', function(){ this.classList.remove('drop-target'); });
|
||
zone.addEventListener('drop', function(e){
|
||
e.preventDefault(); this.classList.remove('drop-target');
|
||
var pid = parseInt(e.dataTransfer.getData('text/plain'));
|
||
var cp = pageById(pid); var g = groupProp();
|
||
if (!cp || !g) return;
|
||
var key = this.dataset.dropCol;
|
||
var val = key === '__none__' ? '' : key;
|
||
if (g.prop_type === 'person') {
|
||
var m = (state.members||[]).filter(function(x){ return (x.login||x.full_name) === key; })[0];
|
||
val = m ? [m] : [];
|
||
} else if (g.prop_type === 'multi_select') {
|
||
val = key === '__none__' ? [] : [key];
|
||
}
|
||
setPageVal(cp, g, val); render();
|
||
savePage(cp);
|
||
});
|
||
});
|
||
// board add card
|
||
containerEl.querySelectorAll('[data-add-col]').forEach(function(b){
|
||
b.onclick = function(e){ e.stopPropagation(); addNewPage(this.dataset.addCol); };
|
||
});
|
||
// calendar nav (v5.8.0: mode-aware)
|
||
function stepCursor(dir) {
|
||
var c = state.calCursor;
|
||
if (calMode() === 'month') state.calCursor = new Date(c.getFullYear(), c.getMonth()+dir, 1);
|
||
else if (calMode() === 'week') { var w = new Date(c); w.setDate(w.getDate()+7*dir); state.calCursor = w; }
|
||
else { var d = new Date(c); d.setDate(d.getDate()+dir); state.calCursor = d; }
|
||
render();
|
||
}
|
||
var prev = containerEl.querySelector('[data-cal-prev]');
|
||
if (prev) prev.onclick = function(){ stepCursor(-1); };
|
||
var next = containerEl.querySelector('[data-cal-next]');
|
||
if (next) next.onclick = function(){ stepCursor(1); };
|
||
var today = containerEl.querySelector('[data-cal-today]');
|
||
if (today) today.onclick = function(){ state.calCursor = new Date(); render(); };
|
||
containerEl.querySelectorAll('[data-cal-mode]').forEach(function(b){
|
||
b.onclick = function(){
|
||
var m = this.dataset.calMode;
|
||
if (m !== calMode()) { persistConfig({calendar_mode: m}, {silent:true}); render(); }
|
||
};
|
||
});
|
||
if (state.viewType === 'calendar' && containerEl.querySelector('#db-cal-body')) refreshCalendar();
|
||
var galAdd = containerEl.querySelector('[data-gallery-add]');
|
||
if (galAdd) galAdd.onclick = function(){ addNewPage(); };
|
||
}
|
||
|
||
function bindResize(handle) {
|
||
handle.onpointerdown = function(e) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
var propId = parseInt(this.dataset.propId);
|
||
var th = this.parentElement;
|
||
var thLeft = th.getBoundingClientRect().left;
|
||
function onMove(ev) {
|
||
var newW = Math.max(60, ev.clientX - thLeft);
|
||
state.colWidths[propId] = newW;
|
||
th.style.width = newW+'px'; th.style.minWidth = newW+'px';
|
||
var colIdx = state.properties.findIndex(function(p){ return p.id === propId; });
|
||
if (colIdx >= 0) containerEl.querySelectorAll('.db-row').forEach(function(row){
|
||
var cells = row.querySelectorAll('.db-td-cell');
|
||
if (cells[colIdx]) cells[colIdx].style.minWidth = newW+'px';
|
||
});
|
||
}
|
||
function onUp() { document.removeEventListener('pointermove',onMove); document.removeEventListener('pointerup',onUp); document.body.style.cursor=''; document.body.style.userSelect=''; }
|
||
document.body.style.cursor='col-resize'; document.body.style.userSelect='none';
|
||
document.addEventListener('pointermove',onMove); document.addEventListener('pointerup',onUp);
|
||
};
|
||
}
|
||
|
||
// ── Cell editing ──
|
||
function startEditCell(pageId, propId, cellEl) {
|
||
var cp = pageById(pageId);
|
||
var prop = propsById()[String(propId)];
|
||
if (!cp || !prop || READONLY_TYPES.indexOf(prop.prop_type) >= 0) return;
|
||
var val = pageVal(cp, prop);
|
||
var t = prop.prop_type;
|
||
if (t === 'person') { openPersonPicker(cellEl, cp, prop); return; }
|
||
var input;
|
||
cellEl.innerHTML = ''; cellEl.classList.add('db-cell-editing');
|
||
if (t === 'checkbox') {
|
||
setPageVal(cp, prop, !val); render(); savePage(cp); return;
|
||
} else if (t === 'select' || t === 'status') {
|
||
input = document.createElement('select'); input.className = 'db-cell-input';
|
||
input.innerHTML = '<option value=""></option>' + parseOpts(prop).map(function(o){ return '<option'+(o.name===val?' selected':'')+'>'+escHtml(o.name)+'</option>'; }).join('');
|
||
} else if (t === 'date') {
|
||
input = document.createElement('input'); input.type = 'date'; input.className = 'db-cell-input';
|
||
input.value = val ? String(val).slice(0,10) : '';
|
||
} else if (t === 'number') {
|
||
input = document.createElement('input'); input.type = 'number'; input.className = 'db-cell-input'; input.value = val==null?'':val;
|
||
} else {
|
||
input = document.createElement('input'); input.type = 'text'; input.className = 'db-cell-input'; input.value = textValue(prop, val);
|
||
}
|
||
cellEl.appendChild(input); input.focus();
|
||
if (input.select) input.select();
|
||
state.editingCell = {pageId:pageId, propId:propId, input:input, cellEl:cellEl, prop:prop};
|
||
input.onkeydown = function(e){ if (e.key === 'Enter') commitEditCell(); else if (e.key === 'Escape') cancelEditCell(); };
|
||
input.onblur = function(){ commitEditCell(); };
|
||
}
|
||
|
||
function commitEditCell() {
|
||
if (!state.editingCell) return;
|
||
var ed = state.editingCell; state.editingCell = null;
|
||
var cp = pageById(ed.pageId); var prop = ed.prop;
|
||
var val = ed.input.value;
|
||
if (prop.prop_type === 'multi_select') val = val ? val.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : [];
|
||
if (prop.prop_type === 'number' && val !== '') val = Number(val);
|
||
if (prop.prop_type === 'checkbox') val = !!val;
|
||
setPageVal(cp, prop, val);
|
||
render();
|
||
savePage(cp, ed.cellEl);
|
||
}
|
||
function cancelEditCell() { state.editingCell = null; render(); }
|
||
|
||
function openPersonPicker(cellEl, cp, prop) {
|
||
var old = document.querySelector('.db-person-picker'); if (old) old.remove();
|
||
var cur = pageVal(cp, prop); if (!Array.isArray(cur)) cur = [];
|
||
var menu = document.createElement('div');
|
||
menu.className = 'db-person-picker';
|
||
menu.innerHTML = (state.members||[]).map(function(m){
|
||
var checked = cur.some(function(x){ return String(x.id) === String(m.id); });
|
||
return '<div class="db-person-opt'+(checked?' checked':'')+'" data-mid="'+m.id+'">'+avatarHtml(m)+'<span>'+escHtml(m.full_name||m.login)+'</span>'+(checked?'<span style="margin-left:auto">✓</span>':'')+'</div>';
|
||
}).join('') || '<div class="db-person-opt">No members</div>';
|
||
document.body.appendChild(menu);
|
||
var r = cellEl.getBoundingClientRect();
|
||
menu.style.left = r.left+'px'; menu.style.top = (r.bottom+4)+'px';
|
||
menu.querySelectorAll('[data-mid]').forEach(function(opt){
|
||
opt.onclick = function(){
|
||
var m = (state.members||[]).filter(function(x){ return String(x.id) === String(this.dataset.mid); }.bind(this))[0];
|
||
var idx = cur.findIndex(function(x){ return String(x.id) === String(m.id); });
|
||
if (idx >= 0) cur.splice(idx,1); else cur.push(m);
|
||
setPageVal(cp, prop, cur.slice());
|
||
menu.remove(); render(); savePage(cp);
|
||
};
|
||
});
|
||
setTimeout(function(){
|
||
document.addEventListener('click', function onDoc(e){ if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
|
||
}, 0);
|
||
}
|
||
|
||
function savePage(cp, cellEl) {
|
||
var pv = pvOf(cp);
|
||
return fetch('/db/pages/'+cp.id+'/api', {
|
||
method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||
body: JSON.stringify({title: cp.title||'', properties: pv})
|
||
}).then(function(r){
|
||
if (!r.ok) return r.json().then(function(d){ throw {status:r.status, detail:(d&&d.detail)||('Error '+r.status)}; });
|
||
return r.json();
|
||
}).catch(function(err){
|
||
var msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save');
|
||
if (cellEl && err && err.status === 400) { cellEl.classList.add('db-cell-invalid'); cellEl.title = msg; }
|
||
toast('⚠ '+msg, 'error');
|
||
});
|
||
}
|
||
|
||
// ── v5.9.0: AI property suggestions ──
|
||
function aiFillRow(pageId) {
|
||
var cp = pageById(pageId); if (!cp) return;
|
||
var props = state.properties.filter(function(p){ return p.prop_type !== 'title' && READONLY_TYPES.indexOf(p.prop_type) < 0; });
|
||
if (!props.length) { toast('Aucune propriété à remplir'); return; }
|
||
toast('✨ FlowDeck AI : analyse en cours…');
|
||
fetch('/api/agent/writing/properties', {
|
||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
|
||
body: JSON.stringify({title: cp.title||'', content: cp.content||'', properties: props.map(function(p){ return {name:p.name, type:p.prop_type}; })})
|
||
}).then(function(r){ return r.json(); }).then(function(d){
|
||
if (!d || !d.ok) { toast((d&&d.error)||'Échec de la génération'); return; }
|
||
var filled = 0;
|
||
props.forEach(function(p){
|
||
var v = d.suggestions ? d.suggestions[p.name] : undefined;
|
||
if (v !== undefined && v !== null && v !== '') { setPageVal(cp, p, v); filled++; }
|
||
});
|
||
render(); savePage(cp);
|
||
toast(filled ? ('✨ '+filled+' propriété(s) remplie(s)') : 'Aucune suggestion disponible');
|
||
}).catch(function(){ toast('Erreur IA', 'error'); });
|
||
}
|
||
|
||
function addNewPage(colKey) {
|
||
var body = {title:''};
|
||
var g = groupProp();
|
||
if (colKey !== undefined && g && colKey !== '__none__') {
|
||
if (g.prop_type === 'person') {
|
||
var m = (state.members||[]).filter(function(x){ return (x.login||x.full_name) === colKey; })[0];
|
||
body.properties = {}; body.properties[String(g.id)] = m ? [m] : [];
|
||
} else if (g.prop_type === 'multi_select') {
|
||
body.properties = {}; body.properties[String(g.id)] = [colKey];
|
||
} else {
|
||
body.properties = {}; body.properties[String(g.id)] = colKey;
|
||
}
|
||
}
|
||
fetch('/api/collections/'+collectionId+'/pages', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify(body)})
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){ if (d.id) { state.pages.push({id:d.id,title:'',icon:'file',collection_id:collectionId,property_values_json:d.property_values_json||body.properties||{}}); render(); } })
|
||
.catch(function(e){ console.error('[DB] addNewPage error:', e); });
|
||
}
|
||
|
||
// ── Property modal ──
|
||
function showPropModal() {
|
||
var old = containerEl.querySelector('.db-prop-modal-inline'); if (old) old.remove();
|
||
var modal = document.createElement('div');
|
||
modal.className = 'db-prop-modal-inline';
|
||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:1000;display:flex;align-items:center;justify-content:center';
|
||
modal.innerHTML = '<div class="db-modal" style="background:var(--bg-modal);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,0.15);width:440px;max-width:90vw;max-height:90vh;overflow:auto">'+
|
||
'<div class="db-modal-header"><h3>Add a property</h3><button class="db-prop-modal-close-btn" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;color:var(--text-dim);font-size:18px">×</button></div>'+
|
||
'<div class="db-modal-body" style="padding:16px">'+
|
||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Name</label><input class="db-prop-name-inline" type="text" placeholder="Property name" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></div>'+
|
||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Type</label><select class="db-prop-type-inline" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"><option value="text">Text</option><option value="number">Number</option><option value="select">Select</option><option value="multi_select">Multi-select</option><option value="status">Status</option><option value="date">Date</option><option value="person">Person</option><option value="checkbox">Checkbox</option><option value="url">URL</option><option value="email">Email</option><option value="phone">Phone</option><option value="files">Files</option><option value="created_time">Created time</option><option value="created_by">Created by</option><option value="last_edited_time">Last edited time</option><option value="last_edited_by">Last edited by</option></select></div>'+
|
||
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Group (optional)</label><input class="db-prop-group-inline" type="text" placeholder="e.g. Basics" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></div>'+
|
||
'<div style="display:flex;gap:16px;margin-bottom:12px"><label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-required-inline" type="checkbox"> Required</label><label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-unique-inline" type="checkbox"> Unique</label></div>'+
|
||
'<div style="display:flex;gap:10px;margin-bottom:12px"><div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Min</label><input class="db-prop-min-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div><div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Max</label><input class="db-prop-max-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div></div>'+
|
||
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px"><button class="db-prop-cancel-inline" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-secondary);font-size:13px;cursor:pointer">Cancel</button><button class="db-prop-create-inline" style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent,#2383E2);color:white;font-size:13px;cursor:pointer">Create</button></div>'+
|
||
'</div></div>';
|
||
document.body.appendChild(modal);
|
||
var nameInput = modal.querySelector('.db-prop-name-inline');
|
||
nameInput.focus();
|
||
modal.querySelector('.db-prop-modal-close-btn').onclick = function(){ modal.remove(); };
|
||
modal.querySelector('.db-prop-cancel-inline').onclick = function(){ modal.remove(); };
|
||
modal.onclick = function(e){ if (e.target === modal) modal.remove(); };
|
||
modal.querySelector('.db-prop-create-inline').onclick = function(){
|
||
var name = nameInput.value.trim(); if (!name) return;
|
||
var type = modal.querySelector('.db-prop-type-inline').value;
|
||
var group = modal.querySelector('.db-prop-group-inline').value.trim();
|
||
var validation = {};
|
||
if (modal.querySelector('.db-prop-required-inline').checked) validation.required = true;
|
||
if (modal.querySelector('.db-prop-unique-inline').checked) validation.unique = true;
|
||
var min = modal.querySelector('.db-prop-min-inline').value;
|
||
var max = modal.querySelector('.db-prop-max-inline').value;
|
||
if (min !== '') validation.min = Number(min);
|
||
if (max !== '') validation.max = Number(max);
|
||
fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:name, prop_type:type, group_name:group, validation:validation})})
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){ if (d.id) { d.options_json = d.options_json || '[]'; d.group_name = group; state.properties.push(d); render(); modal.remove(); } })
|
||
.catch(function(e){ console.error('[DB] createProperty error:', e); });
|
||
};
|
||
nameInput.onkeydown = function(e){ if (e.key === 'Enter') modal.querySelector('.db-prop-create-inline').click(); };
|
||
}
|
||
|
||
// ── Column context menu ──
|
||
function showColMenu(propId, propType, propName, anchor) {
|
||
var old = document.querySelector('.db-col-menu-inline'); if (old) old.remove();
|
||
var menu = document.createElement('div');
|
||
menu.className = 'db-col-menu-inline';
|
||
menu.style.cssText = 'position:fixed;min-width:240px;max-width:320px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.14);z-index:800;padding:4px 0;font-size:13px;color:var(--text-primary)';
|
||
var rect = anchor.getBoundingClientRect();
|
||
menu.style.top = (rect.bottom+4)+'px'; menu.style.left = rect.left+'px';
|
||
menu.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;font-weight:600"><span>'+propTypeSvg(propType)+'</span><span>'+escHtml(propName)+'</span><span style="font-size:11px;color:var(--text-dim)">ⓘ</span></div>'+
|
||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||
'<div class="db-cm" data-cm="filter" style="padding:6px 12px;cursor:pointer">≡ Filter</div>'+
|
||
'<div class="db-cm" data-cm="sort" style="padding:6px 12px;cursor:pointer">↕ Sort</div>'+
|
||
'<div class="db-cm" data-cm="group" style="padding:6px 12px;cursor:pointer">▦ Group by this</div>'+
|
||
'<div class="db-cm" data-cm="groupname" style="padding:6px 12px;cursor:pointer">🗂 Move to group…</div>'+
|
||
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
|
||
'<div class="db-cm" data-cm="left" style="padding:6px 12px;cursor:pointer">├← Insert left</div>'+
|
||
'<div class="db-cm" data-cm="right" style="padding:6px 12px;cursor:pointer">→┤ Insert right</div>';
|
||
document.body.appendChild(menu);
|
||
menu.querySelectorAll('[data-cm]').forEach(function(item){
|
||
item.onclick = function(){
|
||
var act = this.dataset.cm; menu.remove();
|
||
if (act === 'group') { persistConfig({group_by: propId}); toast('Grouped by '+propName); }
|
||
else if (act === 'groupname') {
|
||
var g = prompt('Group name for "'+propName+'" (empty to ungroup):', (findProp(propId)||{}).group_name || '');
|
||
if (g !== null) {
|
||
fetch('/db/properties/'+propId+'/api', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({group_name:g.trim()})})
|
||
.then(function(){ var p = findProp(propId); if (p) p.group_name = g.trim(); render(); });
|
||
}
|
||
}
|
||
};
|
||
});
|
||
setTimeout(function(){
|
||
document.addEventListener('click', function onDoc(e){ if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
|
||
}, 0);
|
||
}
|
||
|
||
// ── View settings popover ──
|
||
function closePop() { var p = document.querySelector('.db-view-pop'); if (p) p.remove(); }
|
||
function openPop(anchor, html) {
|
||
closePop();
|
||
var pop = document.createElement('div');
|
||
pop.className = 'db-view-pop';
|
||
pop.innerHTML = html;
|
||
document.body.appendChild(pop);
|
||
var r = anchor.getBoundingClientRect();
|
||
pop.style.left = Math.min(r.left, window.innerWidth - 380) + 'px';
|
||
pop.style.top = (r.bottom + 6) + 'px';
|
||
setTimeout(function(){
|
||
document.addEventListener('click', function onDoc(e){ if (!pop.contains(e.target) && !anchor.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDoc); } }, {once:true});
|
||
}, 0);
|
||
return pop;
|
||
}
|
||
function propSelect(name, selected, filterFn) {
|
||
var opts = state.properties.filter(filterFn || function(){ return true; }).map(function(p){
|
||
return '<option value="'+p.id+'"'+(String(p.id)===String(selected)?' selected':'')+'>'+escHtml(p.name)+'</option>';
|
||
}).join('');
|
||
return '<select data-pop-field="'+name+'"><option value="">—</option>'+opts+'</select>';
|
||
}
|
||
function openViewSettings(anchor) {
|
||
var v = activeView();
|
||
var t = state.viewType;
|
||
var h = '<div class="pop-title">'+viewSvg(t)+' View settings</div>';
|
||
h += '<div class="pop-row"><label>Name</label><input type="text" data-pop-name value="'+escHtml(v?v.name:'')+'"></div>';
|
||
if (t === 'board') {
|
||
var g = groupProp();
|
||
h += '<div class="pop-row"><label>Group by</label>'+propSelect('group_by', state.viewConfig.group_by || (g?g.id:''), function(p){ return ['select','status','person','multi_select','text'].indexOf(p.prop_type)>=0; })+'</div>';
|
||
h += '<div class="pop-row"><label>Swimlanes (sub-group)</label>'+propSelect('sub_group_by', state.viewConfig.sub_group_by, function(p){ return ['select','status','person'].indexOf(p.prop_type)>=0; })+'</div>';
|
||
h += '<div class="pop-row"><label>Card size</label><select data-pop-field="card_size"><option value="small"'+(state.viewConfig.card_size==='small'?' selected':'')+'>Compact</option><option value="medium"'+((state.viewConfig.card_size||'medium')==='medium'?' selected':'')+'>Default</option></select></div>';
|
||
h += '<div class="pop-row"><label>Cover</label><select data-pop-field="cover_mode"><option value="none"'+(state.viewConfig.cover_mode==='none'?' selected':'')+'>None</option><option value="icon"'+((state.viewConfig.cover_mode||'icon')==='icon'?' selected':'')+'>Icon</option><option value="color"'+(state.viewConfig.cover_mode==='color'?' selected':'')+'>Color</option><option value="property"'+(state.viewConfig.cover_mode==='property'?' selected':'')+'>Property</option></select></div>';
|
||
if (state.viewConfig.cover_mode === 'property') h += '<div class="pop-row"><label>Cover property</label>'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'</div>';
|
||
h += '<div class="pop-sep"></div><div class="pop-hint">WIP limits (leave empty for none)</div>';
|
||
optionColumns(g).forEach(function(col){
|
||
if (col.key === '__none__') return;
|
||
var lim = (state.viewConfig.wip_limits||{})[col.key] || '';
|
||
h += '<div class="pop-row"><label>'+escHtml(col.label)+'</label><input type="number" min="0" data-wip="'+escHtml(col.key)+'" value="'+lim+'" style="width:70px"></div>';
|
||
});
|
||
h += '<div class="pop-sep"></div><div class="pop-hint">Card properties</div><div class="pop-chips">';
|
||
state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){
|
||
var on = (state.viewConfig.card_properties||defaultCardProps()).some(function(x){ return String(x)===String(p.id); });
|
||
h += '<span class="pop-chip'+(on?' on':'')+'" data-cardprop="'+p.id+'">'+escHtml(p.name)+'</span>';
|
||
});
|
||
h += '</div>';
|
||
} else if (t === 'calendar') {
|
||
h += '<div class="pop-row"><label>Date property</label>'+propSelect('date_property', state.viewConfig.date_property, function(p){ return p.prop_type==='date'; })+'</div>';
|
||
} else if (t === 'gallery') {
|
||
h += '<div class="pop-row"><label>Card size</label><select data-pop-field="card_size"><option value="small"'+(state.viewConfig.card_size==='small'?' selected':'')+'>Small</option><option value="medium"'+((state.viewConfig.card_size||'medium')==='medium'?' selected':'')+'>Medium</option><option value="large"'+(state.viewConfig.card_size==='large'?' selected':'')+'>Large</option></select></div>';
|
||
h += '<div class="pop-row"><label>Cover</label><select data-pop-field="cover_mode"><option value="none"'+(state.viewConfig.cover_mode==='none'?' selected':'')+'>None</option><option value="icon"'+((state.viewConfig.cover_mode||'icon')==='icon'?' selected':'')+'>Icon</option><option value="color"'+(state.viewConfig.cover_mode==='color'?' selected':'')+'>Color</option><option value="property"'+(state.viewConfig.cover_mode==='property'?' selected':'')+'>Property</option></select></div>';
|
||
if (state.viewConfig.cover_mode === 'property') h += '<div class="pop-row"><label>Cover property</label>'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'</div>';
|
||
h += '<div class="pop-sep"></div><div class="pop-hint">Card properties</div><div class="pop-chips">';
|
||
state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){
|
||
var on = (state.viewConfig.card_properties||defaultCardProps()).some(function(x){ return String(x)===String(p.id); });
|
||
h += '<span class="pop-chip'+(on?' on':'')+'" data-cardprop="'+p.id+'">'+escHtml(p.name)+'</span>';
|
||
});
|
||
h += '</div>';
|
||
} else if (t === 'table') {
|
||
h += '<div class="pop-hint">Assign properties to collapsible groups. Group headers appear above the table.</div>';
|
||
state.properties.forEach(function(p){
|
||
h += '<div class="pop-row"><label>'+escHtml(p.name)+'</label><input type="text" data-groupassign="'+p.id+'" value="'+escHtml(p.group_name||'')+'" style="width:130px"></div>';
|
||
});
|
||
} else {
|
||
h += '<div class="pop-hint">List uses the first properties. Configure card properties in a Board or Gallery view.</div>';
|
||
}
|
||
h += '<div class="pop-sep"></div>';
|
||
h += '<div class="pop-item" data-pop-action="duplicate">⧉ Duplicate view</div>';
|
||
h += '<div class="pop-item" data-pop-action="delete" style="color:#e5484d">🗑 Delete view</div>';
|
||
var pop = openPop(anchor, h);
|
||
// Wire name
|
||
var nameInput = pop.querySelector('[data-pop-name]');
|
||
if (nameInput) nameInput.onchange = function(){ if (v) { persistConfig({}, {name:this.value, silent:true}); render(); } };
|
||
// Wire selects / inputs
|
||
pop.querySelectorAll('[data-pop-field]').forEach(function(el){
|
||
el.onchange = function(){
|
||
var patch = {}; patch[this.dataset.popField] = this.value; persistConfig(patch);
|
||
};
|
||
});
|
||
pop.querySelectorAll('[data-wip]').forEach(function(el){
|
||
el.onchange = function(){
|
||
var limits = Object.assign({}, state.viewConfig.wip_limits || {});
|
||
if (this.value === '') delete limits[this.dataset.wip]; else limits[this.dataset.wip] = Number(this.value);
|
||
persistConfig({wip_limits: limits});
|
||
};
|
||
});
|
||
pop.querySelectorAll('[data-cardprop]').forEach(function(el){
|
||
el.onclick = function(){
|
||
var arr = (state.viewConfig.card_properties || defaultCardProps()).slice();
|
||
var id = Number(this.dataset.cardprop);
|
||
var idx = arr.findIndex(function(x){ return String(x)===String(id); });
|
||
if (idx >= 0) arr.splice(idx,1); else arr.push(id);
|
||
persistConfig({card_properties: arr});
|
||
};
|
||
});
|
||
pop.querySelectorAll('[data-groupassign]').forEach(function(el){
|
||
el.onchange = function(){
|
||
fetch('/db/properties/'+this.dataset.groupassign+'/api', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({group_name:this.value.trim()})})
|
||
.then(function(){ var p = findProp(this.dataset.groupassign); if (p) p.group_name = this.value.trim(); }.bind(this))
|
||
.then(function(){ render(); });
|
||
};
|
||
});
|
||
pop.querySelectorAll('[data-pop-action]').forEach(function(el){
|
||
el.onclick = function(){
|
||
var act = this.dataset.popAction; pop.remove();
|
||
if (act === 'duplicate' && v) {
|
||
fetch('/db/views/'+v.id+'/duplicate', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body:'{}'})
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } });
|
||
} else if (act === 'delete' && v) {
|
||
if (!confirm('Delete this view?')) return;
|
||
fetch('/db/views/'+v.id+'/api', {method:'DELETE', headers:{'X-CSRF-Token':getCsrf()}}).then(function(){
|
||
state.views = state.views.filter(function(x){ return x.id !== v.id; });
|
||
applyView(state.views[0] || null); render();
|
||
});
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
function openAddViewMenu(anchor) {
|
||
var h = '<div class="pop-title">Add a view</div>';
|
||
['table','board','calendar','gallery','list'].forEach(function(t){
|
||
h += '<div class="pop-item" data-addview="'+t+'">'+viewSvg(t)+' '+t.charAt(0).toUpperCase()+t.slice(1)+'</div>';
|
||
});
|
||
var pop = openPop(anchor, h);
|
||
pop.querySelectorAll('[data-addview]').forEach(function(el){
|
||
el.onclick = function(){
|
||
var t = this.dataset.addview; pop.remove();
|
||
fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:t.charAt(0).toUpperCase()+t.slice(1), view_type:t, config:{}})})
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } });
|
||
};
|
||
});
|
||
}
|
||
|
||
// ── Init ──
|
||
function loadMembers() {
|
||
if (state.members) return Promise.resolve(state.members);
|
||
return fetch('/db/'+collectionId+'/members/api').then(function(r){ return r.json(); }).then(function(d){ state.members = (d && d.members) || []; return state.members; }).catch(function(){ state.members = []; });
|
||
}
|
||
|
||
if (!initialData) {
|
||
fetch('/api/collections/'+collectionId+'/table-data').then(function(r){ return r.json(); }).then(function(d){
|
||
state.collection = d.collection || {}; state.properties = d.properties || []; state.pages = d.pages || []; state.views = d.views || [];
|
||
ensureView().then(loadMembers).then(render);
|
||
}).catch(function(e){ console.error('[DB] load error:', e); });
|
||
} else {
|
||
ensureView().then(loadMembers).then(render);
|
||
}
|
||
}
|
||
|
||
// ── Global API ──
|
||
window.FlowDeckDB = {
|
||
/** Render a database table into a container element. */
|
||
renderInto: function(containerEl, collectionId, initialData) {
|
||
new DBInstance(containerEl, collectionId, initialData);
|
||
}
|
||
};
|
||
|
||
// ── Full-page init (when page_editor_collection.html is used) ──
|
||
var PAGE_COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
|
||
if (PAGE_COLLECTION_ID > 0) {
|
||
var container = document.getElementById('db-table-container');
|
||
if (container) {
|
||
new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG);
|
||
}
|
||
}
|
||
|
||
// ── Global Side Peek for Database Pages (auto-creates panel) ──
|
||
function openPageInSidePeek(pageId) {
|
||
var panel = document.getElementById('db-side-peek');
|
||
if (!panel) {
|
||
panel = document.createElement('div');
|
||
panel.id = 'db-side-peek';
|
||
panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column';
|
||
panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+
|
||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+
|
||
'<button id="db-side-peek-close" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)">×</button>'+
|
||
'<button id="db-side-peek-full" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)" title="Open full page">↗</button>'+
|
||
'</div>'+
|
||
'<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>';
|
||
document.body.appendChild(panel);
|
||
document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; };
|
||
document.getElementById('db-side-peek-full').onclick = function(){
|
||
var ifr = document.getElementById('db-side-peek-iframe');
|
||
if (ifr && ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank');
|
||
};
|
||
var rh = document.getElementById('db-side-peek-resize');
|
||
rh.onpointerdown = function(e) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
var sx = e.clientX, sw = panel.offsetWidth;
|
||
function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; }
|
||
function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; }
|
||
document.body.style.cursor='col-resize';
|
||
document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up);
|
||
};
|
||
}
|
||
var iframe = document.getElementById('db-side-peek-iframe');
|
||
// v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId}
|
||
// opened an unrelated page with the same id) — resolve the row's
|
||
// content page first (lazy-created), then load the real editor.
|
||
resolveRowContentPage(pageId).then(function(pid){
|
||
var fr = document.getElementById('db-side-peek-iframe');
|
||
if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; }
|
||
});
|
||
if (iframe) panel.style.display = 'flex';
|
||
}
|
||
|
||
function resolveRowContentPage(rowId){
|
||
return fetch('/db/pages/'+rowId+'/open/api')
|
||
.then(function(r){ return r.ok ? r.json() : null; })
|
||
.then(function(d){ return (d && d.page_id) ? d.page_id : rowId; })
|
||
.catch(function(){ return rowId; });
|
||
}
|
||
})();
|