// 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; 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); }); // 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