Files
flowdeck/app/templates/_database_table_scripts.html
T
bruno 5951c707eb
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 10m51s
FlowDeck CI / docker (push) Successful in 1m21s
feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
2026-09-24 08:28:25 -04:00

1433 lines
100 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- ═══════════ Database Views — Vanilla JS (v5.7.0) ═══════════ -->
<style>
.db-cell-invalid{outline:2px solid #e5484d;outline-offset:-1px;border-radius:4px}
.db-cell-value.db-cell-readonly{color:var(--text-dim)}
.db-chip{display:inline-flex;align-items:center;gap:4px;padding:1px 7px;border-radius:10px;font-size:12px;background:var(--bg-tertiary);color:var(--text-secondary);margin:1px 2px 1px 0;white-space:nowrap}
.db-chip .db-chip-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.db-person-chip{display:inline-flex;align-items:center;gap:5px;padding:1px 7px 1px 1px;border-radius:12px;font-size:12px;background:var(--bg-tertiary);margin:1px 2px 1px 0}
.db-person-avatar{width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:9px;font-weight:600;flex-shrink:0}
.db-person-picker{position:fixed;z-index:900;min-width:220px;max-height:280px;overflow:auto;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:4px}
.db-person-opt{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13px}
.db-person-opt:hover{background:var(--bg-hover)}
.db-person-opt.checked{background:var(--bg-active)}
.db-group-row th{background:var(--bg-secondary);font-weight:600;font-size:12px;color:var(--text-secondary);text-align:left;padding:4px 10px;border-bottom:1px solid var(--border);border-right:1px solid var(--border)}
.db-group-toggle{cursor:pointer;user-select:none;display:inline-flex;align-items:center;gap:5px}
.db-group-toggle .chev{display:inline-block;transition:transform .12s;font-size:10px}
.db-group-toggle.collapsed .chev{transform:rotate(-90deg)}
.db-view-tab{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:6px;border:none;background:transparent;color:var(--text-secondary);font-size:13px;cursor:pointer;white-space:nowrap}
.db-view-tab:hover{background:var(--bg-hover)}
.db-view-tab.active{background:var(--bg-active);color:var(--text-primary);font-weight:500}
.db-view-tab .db-view-x{opacity:0;font-size:11px;margin-left:2px}
.db-view-tab:hover .db-view-x{opacity:.6}
.db-view-tab .db-view-x:hover{opacity:1;color:#e5484d}
.db-view-settings{cursor:pointer;margin-left:3px;opacity:.55;font-size:12px}
.db-view-settings:hover{opacity:1}
.db-view-pop{position:fixed;z-index:1000;min-width:300px;max-width:360px;max-height:70vh;overflow:auto;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 12px 34px rgba(0,0,0,.2);padding:8px 0;font-size:13px;color:var(--text-primary)}
.db-view-pop .pop-title{padding:6px 14px 8px;font-weight:600;font-size:13px;border-bottom:1px solid var(--border);margin-bottom:4px}
.db-view-pop .pop-row{padding:7px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px}
.db-view-pop .pop-row label{color:var(--text-secondary);font-size:12px}
.db-view-pop .pop-row input[type=text],.db-view-pop .pop-row input[type=number],.db-view-pop .pop-row select{max-width:170px;padding:4px 7px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:12px}
.db-view-pop .pop-sep{height:1px;background:var(--border);margin:5px 0}
.db-view-pop .pop-item{padding:7px 14px;cursor:pointer;display:flex;align-items:center;gap:8px}
.db-view-pop .pop-item:hover{background:var(--bg-hover)}
.db-view-pop .pop-hint{padding:4px 14px 8px;color:var(--text-dim);font-size:11px;line-height:1.4}
.db-view-pop .pop-chips{display:flex;flex-wrap:wrap;gap:4px;padding:4px 14px 8px}
.db-view-pop .pop-chip{padding:3px 9px;border-radius:12px;border:1px solid var(--border);cursor:pointer;font-size:12px;color:var(--text-secondary)}
.db-view-pop .pop-chip.on{background:var(--accent,#2383E2);color:#fff;border-color:transparent}
/* Board */
.db-board{display:flex;gap:12px;overflow-x:auto;padding:6px 2px 14px;align-items:flex-start}
.db-board-col{min-width:250px;max-width:290px;flex:0 0 auto;background:var(--bg-secondary);border:1px solid var(--border);border-radius:10px;display:flex;flex-direction:column;max-height:70vh}
.db-board-col-head{display:flex;align-items:center;gap:7px;padding:9px 11px;font-size:13px;font-weight:600;position:sticky;top:0;border-bottom:1px solid var(--border)}
.db-board-count{color:var(--text-dim);font-weight:400;font-size:12px}
.db-wip{font-size:11px;color:var(--text-dim);margin-left:auto}
.db-board-col.wip-exceeded .db-board-col-head{background:rgba(229,72,77,.12);color:#e5484d}
.db-board-col.wip-exceeded .db-wip{color:#e5484d;font-weight:600}
.db-board-cards{display:flex;flex-direction:column;gap:8px;padding:8px;overflow-y:auto;min-height:40px}
.db-board-cards.drop-target{background:var(--bg-active);outline:2px dashed var(--accent,#2383E2);outline-offset:-4px;border-radius:8px}
.db-card{background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;overflow:hidden;cursor:pointer;transition:box-shadow .1s}
.db-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.14)}
.db-card.dragging{opacity:.45}
.db-card-cover{height:92px;background-size:cover;background-position:center;display:flex;align-items:center;justify-content:center;font-size:34px;color:var(--text-dim)}
.db-card-body{padding:8px 10px;display:flex;flex-direction:column;gap:5px}
.db-card-title{font-size:13px;font-weight:500;line-height:1.3}
.db-card-prop{font-size:11px;color:var(--text-secondary);display:flex;align-items:center;gap:5px}
.db-card-prop .k{color:var(--text-dim)}
.db-card.compact .db-card-body{gap:3px;padding:6px 9px}
.db-card.compact .db-card-prop{display:none}
.db-card-add{margin:0 8px 8px;padding:5px;border:1px dashed var(--border);border-radius:7px;background:transparent;color:var(--text-dim);cursor:pointer;font-size:12px;text-align:left}
.db-card-add:hover{background:var(--bg-hover);color:var(--text-primary)}
/* Swimlanes */
.db-lane{margin-bottom:16px}
.db-lane-head{font-size:13px;font-weight:600;padding:4px 2px 8px;color:var(--text-secondary)}
.db-lane .db-board{padding-top:0}
/* Calendar */
.db-cal-head{display:flex;align-items:center;gap:12px;padding:4px 2px 10px}
.db-cal-title{font-size:15px;font-weight:600;min-width:160px;text-align:center}
.db-cal-nav{display:flex;align-items:center;gap:4px}
.db-cal-nav button{border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);border-radius:6px;width:28px;height:28px;cursor:pointer}
.db-cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:8px;overflow:hidden}
.db-cal-dow{background:var(--bg-secondary);padding:6px;text-align:center;font-size:11px;color:var(--text-dim);font-weight:600}
.db-cal-day{background:var(--bg-primary);min-height:104px;padding:4px;display:flex;flex-direction:column;gap:3px}
.db-cal-day.other{background:var(--bg-secondary);opacity:.6}
.db-cal-day.today .db-cal-daynum{background:var(--accent,#2383E2);color:#fff;border-radius:50%}
.db-cal-day.drop-target{outline:2px dashed var(--accent,#2383E2);outline-offset:-3px}
.db-cal-daynum{font-size:11px;color:var(--text-secondary);width:20px;height:20px;display:flex;align-items:center;justify-content:center}
.db-cal-ev{font-size:11px;padding:2px 6px;border-radius:5px;background:var(--bg-tertiary);color:var(--text-primary);cursor:grab;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.db-cal-ev:hover{background:var(--bg-active)}
.db-cal-ev-time{font-weight:600;margin-right:4px;color:var(--accent,#2383E2)}
.db-ev-badge{display:inline-block;margin-left:4px;font-size:10px;color:var(--text-dim)}
.db-cal-modes{margin-left:auto;display:flex;gap:2px;border:1px solid var(--border);border-radius:7px;overflow:hidden}
.db-cal-mode{border:none;background:var(--bg-primary);color:var(--text-secondary);font-size:12px;padding:4px 10px;cursor:pointer}
.db-cal-mode.active{background:var(--bg-active);color:var(--text-primary);font-weight:600}
.db-cal-day-col{border:1px solid var(--border);border-radius:8px;background:var(--bg-primary);min-height:220px}
.db-cal-dayhead{padding:8px 12px;font-size:13px;font-weight:600;border-bottom:1px solid var(--border)}
.db-cal-agenda-row{display:flex;align-items:center;gap:10px;padding:7px 12px;border-bottom:1px solid var(--border);cursor:pointer;font-size:13px}
.db-cal-agenda-row:hover{background:var(--bg-hover)}
.db-event-pop{position:fixed;z-index:1000;min-width:300px;max-width:360px;max-height:70vh;overflow:auto;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 12px 34px rgba(0,0,0,.2);padding:8px 0;font-size:13px;color:var(--text-primary)}
.db-event-pop .pop-title{padding:6px 14px 8px;font-weight:600;font-size:13px;border-bottom:1px solid var(--border);margin-bottom:4px}
.db-event-pop .pop-row{padding:7px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px}
.db-event-pop .pop-row label{color:var(--text-secondary);font-size:12px}
.db-event-pop .pop-row input,.db-event-pop .pop-row select{max-width:170px;padding:4px 7px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:12px}
.db-event-pop .pop-sep{height:1px;background:var(--border);margin:5px 0}
.db-event-pop>div[style]{padding:0 14px}
/* Gallery */
.db-gallery{display:grid;gap:12px;padding:4px 2px 16px}
.db-gallery.small{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
.db-gallery.medium{grid-template-columns:repeat(auto-fill,minmax(210px,1fr))}
.db-gallery.large{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
.db-gallery .db-card-cover{height:150px}
.db-gallery.small .db-card-cover{height:100px}
.db-gallery.large .db-card-cover{height:200px}
/* List */
.db-list{padding:4px 0}
.db-list-row{display:flex;align-items:center;gap:10px;padding:7px 8px;border-bottom:1px solid var(--border);cursor:pointer;font-size:13px}
.db-list-row:hover{background:var(--bg-hover)}
.db-list-icon{width:18px;text-align:center}
.db-list-title{flex:1;min-width:120px}
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style>
<script data-cfasync="false">
(function() {
'use strict';
// ── SVG Outline Icons ──
function propTypeSvg(type) {
const s = 14;
switch(type) {
case 'text': case 'title':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
case 'number':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="4" x2="18" y2="20"/><line x1="18" y1="4" x2="6" y2="20"/></svg>';
case 'select':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/></svg>';
case 'multi_select':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><rect x="2" y="5" width="4" height="2" rx="0.5"/><rect x="2" y="11" width="4" height="2" rx="0.5"/><rect x="2" y="17" width="4" height="2" rx="0.5"/></svg>';
case 'status':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 9 9"/></svg>';
case 'date':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
case 'checkbox':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
case 'url':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
case 'email':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
case 'phone':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
case 'person':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
case 'files':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
case 'relation': case 'rollup': case 'formula':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 0 10h-2"/><line x1="8" y1="12" x2="16" y2="12"/></svg>';
case 'created_time': case 'last_edited_time':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
case 'created_by': case 'last_edited_by':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1"/></svg>';
default:
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
}
}
var AUTO_TYPES = ['created_time','created_by','last_edited_time','last_edited_by'];
var READONLY_TYPES = AUTO_TYPES.concat(['rollup','formula']);
var PALETTE = {gray:'#8b8b8b',brown:'#9f6b53',orange:'#d9730d',yellow:'#cb912f',green:'#448361',blue:'#337ea9',purple:'#9065b0',pink:'#c14c8a',red:'#d44c47'};
function escHtml(s) { return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function parseJSON(s, d) { try { return typeof s === 'string' ? JSON.parse(s) : (s || d); } catch(e) { 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 uid() { return 'dbx' + Math.random().toString(36).slice(2,9); }
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 propName(ref) { var p = findProp(ref); return p ? p.name : ''; }
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; var acc = 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 today = new Date();
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) {
{% if collection_data %}
new DBInstance(container, PAGE_COLLECTION_ID, {
collection: {{ collection_data.collection | tojson }},
properties: {{ collection_data.properties | tojson }},
pages: {{ collection_data.pages | tojson }},
views: {{ (collection_data.views if collection_data.views is defined else []) | tojson }}
});
{% else %}
new DBInstance(container, PAGE_COLLECTION_ID, null);
{% endif %}
}
}
// ── 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; });
}
})();
</script>