/** * TASKMATE - Mobile Module * ======================== * Touch-Gesten, Hamburger-Menu, Swipe-Navigation */ import { $, $$ } from './utils.js'; class MobileManager { constructor() { // State this.isMenuOpen = false; this.isMobile = false; this.currentView = 'board'; // Swipe state this.touchStartX = 0; this.touchStartY = 0; this.touchCurrentX = 0; this.touchCurrentY = 0; this.touchStartTime = 0; this.isSwiping = false; this.swipeDirection = null; // Touch drag & drop state this.touchDraggedElement = null; this.touchDragPlaceholder = null; this.touchDragStartX = 0; this.touchDragStartY = 0; this.touchDragOffsetX = 0; this.touchDragOffsetY = 0; this.touchDragScrollInterval = null; this.longPressTimer = null; // Constants this.SWIPE_THRESHOLD = 50; this.SWIPE_VELOCITY_THRESHOLD = 0.3; this.MOBILE_BREAKPOINT = 768; this.LONG_PRESS_DURATION = 300; // View order for swipe navigation this.viewOrder = ['board', 'list', 'calendar', 'proposals', 'gitea', 'knowledge']; // DOM elements this.hamburgerBtn = null; this.mobileMenu = null; this.mobileOverlay = null; this.mainContent = null; this.swipeIndicatorLeft = null; this.swipeIndicatorRight = null; } /** * Initialize mobile features */ init() { // Check if mobile this.checkMobile(); window.addEventListener('resize', () => this.checkMobile()); // Cache DOM elements this.hamburgerBtn = $('#hamburger-btn'); this.mobileMenu = $('#mobile-menu'); this.mobileOverlay = $('#mobile-menu-overlay'); this.mainContent = $('.main-content'); this.swipeIndicatorLeft = $('#swipe-indicator-left'); this.swipeIndicatorRight = $('#swipe-indicator-right'); // Bind events this.bindMenuEvents(); this.bindSwipeEvents(); this.bindTouchDragEvents(); // Listen for view changes document.addEventListener('view:changed', (e) => { this.currentView = e.detail?.view || 'board'; this.updateActiveNavItem(this.currentView); }); // Listen for project changes document.addEventListener('projects:loaded', () => { this.populateMobileProjectSelect(); }); // Listen for user updates document.addEventListener('user:updated', () => { this.updateUserInfo(); }); console.log('[Mobile] Initialized'); } /** * Check if current viewport is mobile */ checkMobile() { this.isMobile = window.innerWidth <= this.MOBILE_BREAKPOINT; } // ===================== // HAMBURGER MENU // ===================== /** * Bind menu events */ bindMenuEvents() { // Hamburger button this.hamburgerBtn?.addEventListener('click', () => this.toggleMenu()); // Close button $('#mobile-menu-close')?.addEventListener('click', () => this.closeMenu()); // Overlay click this.mobileOverlay?.addEventListener('click', () => this.closeMenu()); // Navigation items $$('.mobile-nav-item').forEach(item => { item.addEventListener('click', () => { const view = item.dataset.view; this.switchView(view); this.closeMenu(); }); }); // Project selector $('#mobile-project-select')?.addEventListener('change', (e) => { const projectId = parseInt(e.target.value); if (projectId) { document.dispatchEvent(new CustomEvent('project:selected', { detail: { projectId } })); this.closeMenu(); } }); // Admin button $('#mobile-admin-btn')?.addEventListener('click', () => { this.closeMenu(); document.dispatchEvent(new CustomEvent('admin:open')); }); // Logout button $('#mobile-logout-btn')?.addEventListener('click', () => { this.closeMenu(); document.dispatchEvent(new CustomEvent('auth:logout')); }); // Escape key to close document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isMenuOpen) { this.closeMenu(); } }); } /** * Toggle menu open/close */ toggleMenu() { if (this.isMenuOpen) { this.closeMenu(); } else { this.openMenu(); } } /** * Open mobile menu */ openMenu() { this.isMenuOpen = true; this.hamburgerBtn?.classList.add('active'); this.hamburgerBtn?.setAttribute('aria-expanded', 'true'); this.mobileMenu?.classList.add('open'); this.mobileMenu?.setAttribute('aria-hidden', 'false'); this.mobileOverlay?.classList.add('visible'); document.body.classList.add('mobile-menu-open'); // Update user info when menu opens this.updateUserInfo(); this.populateMobileProjectSelect(); // Focus close button setTimeout(() => { $('#mobile-menu-close')?.focus(); }, 300); } /** * Close mobile menu */ closeMenu() { this.isMenuOpen = false; this.hamburgerBtn?.classList.remove('active'); this.hamburgerBtn?.setAttribute('aria-expanded', 'false'); this.mobileMenu?.classList.remove('open'); this.mobileMenu?.setAttribute('aria-hidden', 'true'); this.mobileOverlay?.classList.remove('visible'); document.body.classList.remove('mobile-menu-open'); // Return focus this.hamburgerBtn?.focus(); } /** * Switch to a different view */ switchView(view) { if (!this.viewOrder.includes(view)) return; this.currentView = view; // Update desktop tabs $$('.view-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.view === view); }); // Show/hide views $$('.view').forEach(v => { const viewName = v.id.replace('view-', ''); const isActive = viewName === view; v.classList.toggle('active', isActive); v.classList.toggle('hidden', !isActive); }); // Update mobile nav this.updateActiveNavItem(view); // Dispatch event for other modules document.dispatchEvent(new CustomEvent('view:changed', { detail: { view } })); } /** * Update active nav item in mobile menu */ updateActiveNavItem(view) { $$('.mobile-nav-item').forEach(item => { item.classList.toggle('active', item.dataset.view === view); }); } /** * Populate project select dropdown */ populateMobileProjectSelect() { const select = $('#mobile-project-select'); const desktopSelect = $('#project-select'); if (!select || !desktopSelect) return; // Copy options from desktop select select.innerHTML = desktopSelect.innerHTML; select.value = desktopSelect.value; } /** * Update user info in mobile menu */ updateUserInfo() { const avatar = $('#mobile-user-avatar'); const name = $('#mobile-user-name'); const role = $('#mobile-user-role'); const adminBtn = $('#mobile-admin-btn'); // Get user info from desktop user dropdown const desktopAvatar = $('#user-avatar'); const desktopDropdown = $('.user-dropdown'); if (avatar && desktopAvatar) { avatar.textContent = desktopAvatar.textContent; avatar.style.backgroundColor = desktopAvatar.style.backgroundColor || 'var(--primary)'; } if (name) { const usernameEl = desktopDropdown?.querySelector('.user-info strong'); name.textContent = usernameEl?.textContent || 'Benutzer'; } if (role) { const roleEl = desktopDropdown?.querySelector('.user-info span:not(strong)'); role.textContent = roleEl?.textContent || 'Angemeldet'; } // Show admin button for admins if (adminBtn) { const isAdmin = role?.textContent?.toLowerCase().includes('admin'); adminBtn.classList.toggle('hidden', !isAdmin); } } // ===================== // SWIPE NAVIGATION // ===================== /** * Bind swipe events */ bindSwipeEvents() { if (!this.mainContent) return; this.mainContent.addEventListener('touchstart', (e) => this.handleSwipeStart(e), { passive: true }); this.mainContent.addEventListener('touchmove', (e) => this.handleSwipeMove(e), { passive: false }); this.mainContent.addEventListener('touchend', (e) => this.handleSwipeEnd(e), { passive: true }); this.mainContent.addEventListener('touchcancel', () => this.resetSwipe(), { passive: true }); } /** * Handle swipe start */ handleSwipeStart(e) { if (!this.isMobile) return; // Don't swipe if menu is open if (this.isMenuOpen) return; // Don't swipe if modal is open if ($('.modal-overlay:not(.hidden)')) return; // Don't swipe on specific interactive elements, but allow swipe in column-body const target = e.target; if (target.closest('.modal') || target.closest('.calendar-grid') || target.closest('.knowledge-entry-list') || target.closest('.list-table') || target.closest('input') || target.closest('textarea') || target.closest('select') || target.closest('button') || target.closest('a[href]') || target.closest('.task-card .priority-stars') || target.closest('.task-card .task-counts')) { return; } // Only single touch if (e.touches.length !== 1) return; this.touchStartX = e.touches[0].clientX; this.touchStartY = e.touches[0].clientY; this.touchStartTime = Date.now(); this.isSwiping = false; this.swipeDirection = null; } /** * Handle swipe move */ handleSwipeMove(e) { if (!this.isMobile || this.touchStartX === 0) return; const touch = e.touches[0]; this.touchCurrentX = touch.clientX; this.touchCurrentY = touch.clientY; const deltaX = this.touchCurrentX - this.touchStartX; const deltaY = this.touchCurrentY - this.touchStartY; // Determine direction on first significant movement if (!this.swipeDirection && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) { if (Math.abs(deltaX) > Math.abs(deltaY) * 1.5) { this.swipeDirection = 'horizontal'; this.isSwiping = true; document.body.classList.add('is-swiping'); } else { this.swipeDirection = 'vertical'; this.resetSwipe(); return; } } if (this.swipeDirection !== 'horizontal') return; // Prevent scroll e.preventDefault(); // Show indicators based on current view if (this.currentView === 'board') { // In board view: show column navigation indicators const columns = $$('.column'); const currentColumnIndex = this.getCurrentColumnIndex(); if (deltaX > this.SWIPE_THRESHOLD && currentColumnIndex > 0) { this.swipeIndicatorLeft?.classList.add('visible'); this.swipeIndicatorRight?.classList.remove('visible'); } else if (deltaX < -this.SWIPE_THRESHOLD && currentColumnIndex < columns.length - 1) { this.swipeIndicatorRight?.classList.add('visible'); this.swipeIndicatorLeft?.classList.remove('visible'); } else { this.swipeIndicatorLeft?.classList.remove('visible'); this.swipeIndicatorRight?.classList.remove('visible'); } } else { // In other views: show view navigation indicators const currentIndex = this.viewOrder.indexOf(this.currentView); if (deltaX > this.SWIPE_THRESHOLD && currentIndex > 0) { this.swipeIndicatorLeft?.classList.add('visible'); this.swipeIndicatorRight?.classList.remove('visible'); } else if (deltaX < -this.SWIPE_THRESHOLD && currentIndex < this.viewOrder.length - 1) { this.swipeIndicatorRight?.classList.add('visible'); this.swipeIndicatorLeft?.classList.remove('visible'); } else { this.swipeIndicatorLeft?.classList.remove('visible'); this.swipeIndicatorRight?.classList.remove('visible'); } } } /** * Handle swipe end */ handleSwipeEnd() { if (!this.isSwiping || this.swipeDirection !== 'horizontal') { this.resetSwipe(); return; } const deltaX = this.touchCurrentX - this.touchStartX; const deltaTime = Date.now() - this.touchStartTime; const velocity = Math.abs(deltaX) / deltaTime; // Valid swipe? const isValidSwipe = Math.abs(deltaX) > this.SWIPE_THRESHOLD || velocity > this.SWIPE_VELOCITY_THRESHOLD; if (isValidSwipe) { if (this.currentView === 'board') { // In board view: navigate between columns const columns = $$('.column'); const currentColumnIndex = this.getCurrentColumnIndex(); if (deltaX > 0 && currentColumnIndex > 0) { // Swipe right - previous column this.scrollToColumn(currentColumnIndex - 1); } else if (deltaX < 0 && currentColumnIndex < columns.length - 1) { // Swipe left - next column this.scrollToColumn(currentColumnIndex + 1); } } else { // In other views: navigate between views const currentIndex = this.viewOrder.indexOf(this.currentView); if (deltaX > 0 && currentIndex > 0) { // Swipe right - previous view this.switchView(this.viewOrder[currentIndex - 1]); } else if (deltaX < 0 && currentIndex < this.viewOrder.length - 1) { // Swipe left - next view this.switchView(this.viewOrder[currentIndex + 1]); } } } this.resetSwipe(); } /** * Reset swipe state */ resetSwipe() { this.touchStartX = 0; this.touchStartY = 0; this.touchCurrentX = 0; this.touchCurrentY = 0; this.touchStartTime = 0; this.isSwiping = false; this.swipeDirection = null; document.body.classList.remove('is-swiping'); this.swipeIndicatorLeft?.classList.remove('visible'); this.swipeIndicatorRight?.classList.remove('visible'); } /** * Get current visible column index in mobile board view */ getCurrentColumnIndex() { const boardContainer = $('.board-container'); if (!boardContainer) return 0; const containerWidth = boardContainer.offsetWidth; const scrollLeft = boardContainer.scrollLeft; const columnWidth = 300; // Approximate column width in mobile return Math.round(scrollLeft / columnWidth); } /** * Scroll to specific column in mobile board view */ scrollToColumn(columnIndex) { const boardContainer = $('.board-container'); if (!boardContainer) return; const columnWidth = 300; // Approximate column width in mobile const targetScrollLeft = columnIndex * columnWidth; boardContainer.scrollTo({ left: targetScrollLeft, behavior: 'smooth' }); } // ===================== // TOUCH DRAG & DROP // ===================== /** * Bind touch drag events */ bindTouchDragEvents() { const board = $('#board'); if (!board) return; board.addEventListener('touchstart', (e) => this.handleTouchDragStart(e), { passive: false }); board.addEventListener('touchmove', (e) => this.handleTouchDragMove(e), { passive: false }); board.addEventListener('touchend', (e) => this.handleTouchDragEnd(e), { passive: true }); board.addEventListener('touchcancel', () => this.cancelTouchDrag(), { passive: true }); } /** * Handle touch drag start */ handleTouchDragStart(e) { if (!this.isMobile) return; const taskCard = e.target.closest('.task-card'); if (!taskCard) return; // Cancel if multi-touch if (e.touches.length > 1) { this.cancelTouchDrag(); return; } const touch = e.touches[0]; this.touchDragStartX = touch.clientX; this.touchDragStartY = touch.clientY; // Long press to start drag this.longPressTimer = setTimeout(() => { this.startTouchDrag(taskCard, touch); }, this.LONG_PRESS_DURATION); } /** * Start touch drag */ startTouchDrag(taskCard, touch) { this.touchDraggedElement = taskCard; const rect = taskCard.getBoundingClientRect(); // Calculate offset this.touchDragOffsetX = touch.clientX - rect.left; this.touchDragOffsetY = touch.clientY - rect.top; // Create placeholder this.touchDragPlaceholder = document.createElement('div'); this.touchDragPlaceholder.className = 'task-card touch-drag-placeholder'; this.touchDragPlaceholder.style.height = rect.height + 'px'; taskCard.parentNode.insertBefore(this.touchDragPlaceholder, taskCard); // Style dragged element taskCard.classList.add('touch-dragging'); taskCard.style.position = 'fixed'; taskCard.style.left = rect.left + 'px'; taskCard.style.top = rect.top + 'px'; taskCard.style.width = rect.width + 'px'; taskCard.style.zIndex = '1000'; document.body.classList.add('is-touch-dragging'); // Haptic feedback if (navigator.vibrate) { navigator.vibrate(50); } } /** * Handle touch drag move */ handleTouchDragMove(e) { // Cancel long press if finger moved if (this.longPressTimer && !this.touchDraggedElement) { const touch = e.touches[0]; const deltaX = Math.abs(touch.clientX - this.touchDragStartX); const deltaY = Math.abs(touch.clientY - this.touchDragStartY); if (deltaX > 10 || deltaY > 10) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } return; } if (!this.touchDraggedElement) return; e.preventDefault(); const touch = e.touches[0]; const taskCard = this.touchDraggedElement; // Move element taskCard.style.left = (touch.clientX - this.touchDragOffsetX) + 'px'; taskCard.style.top = (touch.clientY - this.touchDragOffsetY) + 'px'; // Find drop target taskCard.style.pointerEvents = 'none'; const elemBelow = document.elementFromPoint(touch.clientX, touch.clientY); taskCard.style.pointerEvents = ''; const columnBody = elemBelow?.closest('.column-body'); // Remove previous indicators $$('.column-body.touch-drag-over').forEach(el => el.classList.remove('touch-drag-over')); if (columnBody) { columnBody.classList.add('touch-drag-over'); } // Auto-scroll this.autoScrollWhileDragging(touch); } /** * Auto-scroll while dragging near edges */ autoScrollWhileDragging(touch) { const board = $('#board'); if (!board) return; const boardRect = board.getBoundingClientRect(); const scrollThreshold = 50; const scrollSpeed = 8; // Clear existing interval if (this.touchDragScrollInterval) { clearInterval(this.touchDragScrollInterval); this.touchDragScrollInterval = null; } // Scroll left if (touch.clientX < boardRect.left + scrollThreshold) { this.touchDragScrollInterval = setInterval(() => { board.scrollLeft -= scrollSpeed; }, 16); } // Scroll right else if (touch.clientX > boardRect.right - scrollThreshold) { this.touchDragScrollInterval = setInterval(() => { board.scrollLeft += scrollSpeed; }, 16); } } /** * Handle touch drag end */ handleTouchDragEnd(e) { // Clear long press timer if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } if (!this.touchDraggedElement) return; const touch = e.changedTouches[0]; // Find drop target this.touchDraggedElement.style.pointerEvents = 'none'; const elemBelow = document.elementFromPoint(touch.clientX, touch.clientY); this.touchDraggedElement.style.pointerEvents = ''; const columnBody = elemBelow?.closest('.column-body'); if (columnBody) { const columnId = parseInt(columnBody.closest('.column').dataset.columnId); const taskId = parseInt(this.touchDraggedElement.dataset.taskId); const position = this.calculateDropPosition(columnBody, touch.clientY); // Dispatch move event document.dispatchEvent(new CustomEvent('task:move', { detail: { taskId, columnId, position } })); } this.cleanupTouchDrag(); } /** * Calculate drop position in column */ calculateDropPosition(columnBody, mouseY) { const taskCards = Array.from(columnBody.querySelectorAll('.task-card:not(.touch-dragging):not(.touch-drag-placeholder)')); let position = taskCards.length; for (let i = 0; i < taskCards.length; i++) { const rect = taskCards[i].getBoundingClientRect(); if (mouseY < rect.top + rect.height / 2) { position = i; break; } } return position; } /** * Cancel touch drag */ cancelTouchDrag() { if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; } this.cleanupTouchDrag(); } /** * Cleanup after touch drag */ cleanupTouchDrag() { // Clear scroll interval if (this.touchDragScrollInterval) { clearInterval(this.touchDragScrollInterval); this.touchDragScrollInterval = null; } // Reset dragged element if (this.touchDraggedElement) { this.touchDraggedElement.classList.remove('touch-dragging'); this.touchDraggedElement.style.position = ''; this.touchDraggedElement.style.left = ''; this.touchDraggedElement.style.top = ''; this.touchDraggedElement.style.width = ''; this.touchDraggedElement.style.zIndex = ''; this.touchDraggedElement.style.transform = ''; } // Remove placeholder if (this.touchDragPlaceholder) { this.touchDragPlaceholder.remove(); this.touchDragPlaceholder = null; } // Remove indicators $$('.column-body.touch-drag-over').forEach(el => el.classList.remove('touch-drag-over')); document.body.classList.remove('is-touch-dragging'); // Reset state this.touchDraggedElement = null; this.touchDragStartX = 0; this.touchDragStartY = 0; this.touchDragOffsetX = 0; this.touchDragOffsetY = 0; } } // Create and export singleton const mobileManager = new MobileManager(); export default mobileManager;