Files
flowdeck/static/js/app.js
T
bruno aa88cf6665
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Successful in 15m22s
fix: anti-FOUC au chargement complet (x-cloak sur .app-layout) + nettoyage (v7.45.4)
- Symptôme rapporté : clic sur Home → rafale de menus/fenêtres + bande rouge
  « You are offline. Changes will sync when connection is restored. ».
- Constat : le masquage de v7.45.3 ne couvrait QUE les swaps partiels fdLoad.
  Un chargement COMPLET de document (F5, première visite, navigation servie par
  le service worker) peignait toute l'app tant qu'Alpine n'avait pas initialisé
  .app-layout : sidebar brute (sections ouvertes, menu utilisateur) + zone brute
  dont la bande hors ligne (aucun x-cloak dessus). Le symptôme Home n'est par
  ailleurs PAS rejouable en navigateur frais (nav, clic Home en ligne/hors
  ligne, plein chargement → 0 frame de contenu brut, 0 erreur console).
- Fix : x-cloak sur le nœud racine Alpine .app-layout (base.html) — sidebar +
  zone + bandeau masqués jusqu'au montage d'appState(), retrait par Alpine à
  l'init. Le probe asserte que l'attribut est bien retiré et que le node est
  visible en fin de parcours (garde-fou contre la page blanche).
- Suppression de la classe fd-navigating (posée à chaque fdLoad, aucune règle
  CSS dans le dépôt → code mort).
- e2e/probe_nav_perf.spec.js : 4 scénarios avec assertions (nav 5 pages, clic
  Home réel, Home hors ligne avec SW, plein chargement 3 pages) + capture
  screenshot auto si contenu brut + collecte console.

Gates : probe_nav_perf 4/4 · regression_partial_nav + regression_editor_mount
+ regression_logout_dnd + smoke = 6 passed (errs=0 sur 7 pages × 3 passages) ·
pytest 1094 passed · ruff OK · OpenAPI 7.45.4.
2026-10-03 00:54:28 -04:00

484 lines
18 KiB
JavaScript

// FlowDeck Notion UI — Client-side logic
// Alpine.js + SortableJS + HTMX + Mobile support
/* exported openCardDetail */ /* global owner, repo */
// ── 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;
function done() {
loading = false;
}
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);
});
// ── Swap partiel : monter proprement la zone swapée sous Alpine ──
// Lors d'une navigation partielle (fdLoad), les <script src> de la page
// s'exécutent APRÈS le microtask MutationObserver d'Alpine : toute la zone
// .main-wrapper serait initialisée avant que composants ET stores (menu
// contextuel $store.fdCtx, …) existent → « Undefined variable: … » puis
// « reading 'has' » sur les stores. On pose x-ignore sur TOUTE la zone
// pendant le chargement des scripts, puis on démarle quand tous les
// <script src> ont exécuté (load/error) — filet de sécurité 4 s.
// (initTree est idempotent : le _x_marker posé au premier passage évite
// tout double montage.)
var fdLastMw = document.querySelector('.main-wrapper'); // nœud du chargement complet
window.fdRefreshXData = function (name) {
document.querySelectorAll('[x-data="' + name + '()"]').forEach(function (el) {
if (el.__fdRefreshed) return;
var st = el._x_dataStack;
if (!st) return; // sous x-ignore → done() montera
if (st[0] && Object.keys(st[0]).length) return; // déjà monté correctement
el.__fdRefreshed = true; // monté cassé (hors watcher) → on remonte
var fresh = el.cloneNode(true);
el.parentNode.replaceChild(fresh, el);
if (window.htmx) { try { htmx.process(fresh); } catch { /* volontaire */ } }
});
};
document.addEventListener('htmx:afterSwap', function () {
var mw = document.querySelector('.main-wrapper');
// nœud inchangé = swap hors navigation (sidebar, vues) : rien à faire
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
fdLastMw = mw;
mw.__fdSwapWatch = true;
// Avant tout paint (même task que le swap) : la zone est x-ignore, donc non
// montée Alpine — tous ses [x-show] seraient peints à leur valeur brute
// (rafale de menus/panneaux/états visibles une fraction de seconde). On ne
// la peint qu'une fois initTree passé.
mw.style.opacity = '0';
mw.style.pointerEvents = 'none';
mw.setAttribute('x-ignore', '');
var pending = mw.querySelectorAll('script[src]').length;
var reveal = function () {
if (!mw.isConnected) return;
mw.style.opacity = '';
mw.style.pointerEvents = '';
};
var done = function () {
reveal(); // idempotent : appelé même si la branche suivante retourne
if (!mw.__fdSwapWatch) return;
mw.__fdSwapWatch = false;
document.removeEventListener('load', onScript, true);
document.removeEventListener('error', onScript, true);
if (!mw.isConnected || !mw.hasAttribute('x-ignore')) return;
mw.removeAttribute('x-ignore');
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
reveal();
};
var onScript = function (e) {
if (!e.target || e.target.tagName !== 'SCRIPT') return;
if (--pending <= 0) done();
};
if (!pending) {
// Avant le microtask MutationObserver d'Alpine : retirer x-ignore suffit,
// le MO initialisera au checkpoint (initTree de sécurité = no-op).
mw.removeAttribute('x-ignore');
mw.__fdSwapWatch = false;
setTimeout(function () {
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
reveal(); // après initTree : un paint max de blanc, jamais de contenu brut
}, 0);
return;
}
document.addEventListener('load', onScript, true);
document.addEventListener('error', onScript, true);
setTimeout(done, 4000);
});
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
e.preventDefault();
document.dispatchEvent(new CustomEvent('fd-toggle-sidebar'));
}
});
// 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 { /* keep original response on parse failure */ }
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
syncAfterSwap(evt.detail && evt.detail.xhr);
});
})();
// A38 : openCardDetail était définie (corps identique) dans board_fragment
// ET detailed_board — une seule définition globale ici, appelée par les
// onclick/@click des deux fragments (owner/repo globaux fournis par board.js).
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content',
swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}