Files
flowdeck/static/js/app.js
T
bruno 61a33a7891
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Failing after 3h0m32s
FlowDeck CI / docker (push) Skipped
fix(alpine): resolve console errors on editor and agent panel
Initialize coverOpen/coverLoading in editorState, render import file info with x-if instead of x-show (null deref), split x-if/x-for on the agent mention menu, guard m.steps.length and use unique keys for the icon picker. Also reduce htmx swap responses to .main-wrapper so shell scripts (agent panel, base inline) are not re-executed on partial navigation.
2026-09-12 21:55:22 -04:00

390 lines
13 KiB
JavaScript

// FlowDeck Notion UI — Client-side logic
// Alpine.js + SortableJS + HTMX + Mobile support
// ── Frontend Error Capture ──────────────────────────────────
// Intercepte TOUTES les erreurs JS et les envoie au backend.
// Hermes peut les consulter via GET /api/frontend-errors.
(function() {
'use strict';
const MAX_ERRORS = 50;
var _fd_errors = [];
var _fd_throttle = 0;
function _fd_send(error) {
_fd_errors.push(error);
if (_fd_errors.length > MAX_ERRORS) _fd_errors.shift();
// Throttle: max 1 POST per second
var now = Date.now();
if (now - _fd_throttle < 1000) return;
_fd_throttle = now;
try {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/frontend-error', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(error));
} catch { /* fail silently */ }
}
window.addEventListener('error', function(e) {
_fd_send({
type: 'error',
message: e.message || 'Unknown error',
source: e.filename || '',
line: e.lineno || 0,
col: e.colno || 0,
stack: (e.error && e.error.stack) ? String(e.error.stack).substring(0, 2000) : '',
url: window.location.href,
userAgent: navigator.userAgent.substring(0, 200),
time: new Date().toISOString()
});
});
window.addEventListener('unhandledrejection', function(e) {
_fd_send({
type: 'unhandledrejection',
message: e.reason ? (e.reason.message || String(e.reason)) : 'Promise rejected',
stack: (e.reason && e.reason.stack) ? String(e.reason.stack).substring(0, 2000) : '',
url: window.location.href,
userAgent: navigator.userAgent.substring(0, 200),
time: new Date().toISOString()
});
});
// Expose pour le debug console
window.__flowdeck_errors = _fd_errors;
})();
(function() {
'use strict';
// Theme persistence
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
}
// Close context menu on click outside
document.addEventListener('click', function(e) {
const menu = document.getElementById('context-menu');
if (menu && menu.classList.contains('visible')) {
if (!menu.contains(e.target)) {
menu.classList.remove('visible');
}
}
});
// Close context menu on escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const menu = document.getElementById('context-menu');
if (menu) menu.classList.remove('visible');
}
});
// Tooltip system
document.addEventListener('mouseover', function(e) {
const target = e.target.closest('[data-tooltip]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (!tip) return;
tip.textContent = target.getAttribute('data-tooltip');
tip.style.display = 'block';
const rect = target.getBoundingClientRect();
tip.style.left = rect.left + 'px';
tip.style.top = (rect.bottom + 4) + 'px';
});
document.addEventListener('mouseout', function(e) {
const target = e.target.closest('[data-tooltip]');
if (!target) return;
const tip = document.getElementById('tooltip');
if (tip) tip.style.display = 'none';
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Ctrl+O: New chat (placeholder)
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
console.log('New chat shortcut');
}
});
// ── Mobile: Swipe to close sidebar ──
let touchStartX = 0;
let touchStartY = 0;
document.addEventListener('touchstart', function(e) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener('touchend', function(e) {
const sidebar = document.getElementById('sidebar');
if (!sidebar || !sidebar.classList.contains('mobile-open')) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const dx = touchEndX - touchStartX;
const dy = Math.abs(touchEndY - touchStartY);
// Swipe left on sidebar (>50px horizontal, not too vertical)
if (dx < -50 && dy < Math.abs(dx) * 0.5) {
// Close sidebar via Alpine store
const appLayout = document.querySelector('.app-layout');
if (appLayout && appLayout.__x) {
appLayout.__x.$data.mobileSidebarOpen = false;
appLayout.__x.$data.sidebarCollapsed = true;
}
}
});
// ── Mobile: close sidebar on page navigation ──
document.addEventListener('htmx:beforeRequest', function() {
const appLayout = document.querySelector('.app-layout');
if (appLayout && appLayout.__x) {
appLayout.__x.$data.mobileSidebarOpen = false;
}
});
// ── Mobile: passive scroll performance ──
if ('ontouchstart' in window) {
document.querySelectorAll('.kanban-container').forEach(function(el) {
el.style.webkitOverflowScrolling = 'touch';
});
document.querySelectorAll('.table-container').forEach(function(el) {
el.style.webkitOverflowScrolling = 'touch';
});
}
// Modal functions
window.openModal = function(title) {
const modal = document.getElementById('card-modal');
const titleEl = document.getElementById('card-modal-title');
if (modal && titleEl) {
titleEl.textContent = title || 'Détail';
modal.style.display = 'flex';
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
}
};
window.closeModal = function() {
const modal = document.getElementById('card-modal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
};
// Close modal on overlay click
document.addEventListener('click', function(e) {
if (e.target.id === 'card-modal') {
closeModal();
}
});
// Close modal on escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
}
});
// HTMX event handlers
document.addEventListener('htmx:afterSwap', function(evt) {
// Reinitialize Sortable after HTMX swaps
if (evt.target.id === 'kanban-root' || evt.target.closest('.kanban-container')) {
initSortable();
}
});
// Initialize SortableJS on kanban columns
function initSortable() {
document.querySelectorAll('.kanban-cards').forEach(function(el) {
if (el._sortable) return; // Already initialized
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 200,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
// Touch support
delay: 0,
touchStartThreshold: 5,
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const fromCol = evt.from.dataset.status;
const toCol = evt.to.dataset.status;
const group = evt.to.dataset.group;
console.log('Moved card', cardId, 'from', fromCol, 'to', toCol, 'group', group);
}
});
});
}
// Init on page load
document.addEventListener('DOMContentLoaded', function() {
initSortable();
});
// Re-init when Alpine initializes
document.addEventListener('alpine:initialized', function() {
setTimeout(initSortable, 100);
});
})();
// ── Partial navigation (HTMX) ───────────────────────────────
// The shell (sidebar + topbar) stays in place and only `.main-wrapper`
// is swapped, so clicking the left navigation no longer reloads the
// whole document. Internal links are intercepted and loaded with
// htmx.ajax(); history is managed manually so the back button works.
(function() {
'use strict';
function isPlainLeftClick(evt) {
return evt.button === 0 && !evt.ctrlKey && !evt.metaKey && !evt.shiftKey && !evt.altKey;
}
// Returns true when a link should be handled by the partial navigator.
// External links, downloads, auth, API/static files and links already
// managed by Alpine or HTMX keep their default behaviour.
function shouldIntercept(a, evt) {
if (!a || !a.getAttribute || !isPlainLeftClick(evt)) return false;
if (a.hasAttribute('data-no-nav')) return false;
var target = (a.getAttribute('target') || '').toLowerCase();
if (target && target !== '_self') return false;
if (a.hasAttribute('download')) return false;
if (a.getAttribute('hx-boost') === 'false') return false;
var attrs = a.getAttributeNames();
for (var i = 0; i < attrs.length; i++) {
var n = attrs[i];
if (n.indexOf('@click') === 0 || n.indexOf('x-on:click') === 0) return false;
if (n.indexOf('hx-') === 0) return false;
}
var href = a.getAttribute('href') || '';
if (!href || href.charAt(0) === '#') return false;
if (/^(mailto:|tel:|javascript:)/i.test(href)) return false;
var u;
try { u = new URL(a.href, window.location.href); } catch { return false; }
if (u.origin !== window.location.origin) return false;
var p = u.pathname;
if (p === '/auth' || p.indexOf('/auth/') === 0) return false;
if (p.indexOf('/api/') === 0) return false;
if (p.indexOf('/static/') === 0) return false;
if (p === '/p' || p.indexOf('/p/') === 0) return false;
return true;
}
var loading = false;
function fdLoad(url, push) {
if (!window.htmx) { window.location.href = url; return; }
if (loading) return;
loading = true;
document.documentElement.classList.add('fd-navigating');
function done() {
loading = false;
document.documentElement.classList.remove('fd-navigating');
}
var p;
try {
p = window.htmx.ajax('GET', url, {
target: '.main-wrapper',
select: '.main-wrapper',
swap: 'outerHTML'
});
} catch {
done();
window.location.href = url;
return;
}
if (p && typeof p.then === 'function') {
p.then(function() {
done();
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
}).catch(function() {
done();
window.location.href = url;
});
} else {
done();
if (push) { try { history.pushState({ fd: true }, '', url); } catch { /* ignore */ } }
}
}
document.addEventListener('click', function(evt) {
var a = evt.target && evt.target.closest ? evt.target.closest('a[href]') : null;
if (!shouldIntercept(a, evt)) return;
evt.preventDefault();
fdLoad(a.href, true);
});
// Back/forward buttons reload the content for the target URL.
window.addEventListener('popstate', function() {
fdLoad(window.location.href, false);
});
// Programmatic navigation used by Alpine helpers.
window.fdNavigate = function(url, opts) {
opts = opts || {};
if (!url) return;
if (opts.full || !window.htmx) { window.location.href = url; return; }
fdLoad(url, true);
};
function syncAfterSwap(xhr) {
try {
if (xhr && xhr.responseText) {
var doc = new DOMParser().parseFromString(xhr.responseText, 'text/html');
var title = doc.querySelector('title');
if (title && title.textContent) document.title = title.textContent;
}
} catch { /* ignore */ }
var path = window.location.pathname;
var search = window.location.search;
document.querySelectorAll('.sidebar-item.active').forEach(function(el) {
el.classList.remove('active');
});
document.querySelectorAll('.sidebar-item').forEach(function(el) {
var href = el.getAttribute('href');
var a = el.querySelector('a[href]');
if (a) href = a.getAttribute('href');
if (href && (href === path || href === path + search)) el.classList.add('active');
var pid = el.getAttribute('data-page-id');
if (pid && ('/pages/' + String(pid).replace('page/', '')) === path) el.classList.add('active');
var dbid = el.getAttribute('data-id');
if (dbid && search.indexOf('folder=' + dbid) !== -1) el.classList.add('active');
});
if (window.appState) {
var m = search.match(/[?&]folder=(\d+)/);
window.appState.activeNodeId = m ? parseInt(m[1], 10) : null;
}
window.scrollTo(0, 0);
}
// htmx parses the WHOLE response and executes every <script> it contains,
// including shell scripts (agent panel, base inline scripts) that live
// outside .main-wrapper. Re-running those corrupts Alpine state (x-for
// teardown errors). Before the swap, reduce the response to the
// .main-wrapper fragment so only page scripts are executed.
document.body.addEventListener('htmx:beforeSwap', function(evt) {
var d = evt.detail;
if (!d || !d.target || !d.target.classList || !d.target.classList.contains('main-wrapper')) return;
if (!d.serverResponse || typeof d.serverResponse !== 'string') return;
try {
var doc = new DOMParser().parseFromString(d.serverResponse, 'text/html');
var mw = doc.querySelector('.main-wrapper');
if (mw) d.serverResponse = mw.outerHTML;
} catch (e) { /* keep original response on parse failure */ }
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
syncAfterSwap(evt.detail && evt.detail.xhr);
});
})();