FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s
tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe. Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips. init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan). ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config). CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push. VERSION 5.11.1.
236 lines
7.3 KiB
JavaScript
236 lines
7.3 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);
|
|
});
|
|
|
|
})();
|