- 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.
484 lines
18 KiB
JavaScript
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;
|
|
}
|
|
|