Datenbank bereinigt / Gitea-Integration gefixt

Dieser Commit ist enthalten in:
hendrik_gebhardt@gmx.de
2026-01-04 00:24:11 +00:00
committet von Server Deploy
Ursprung 395598c2b0
Commit c21be47428
37 geänderte Dateien mit 30993 neuen und 809 gelöschten Zeilen

696
frontend/js/mobile.js Normale Datei
Datei anzeigen

@ -0,0 +1,696 @@
/**
* 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 scrollable elements
const target = e.target;
if (target.closest('.column-body') ||
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')) {
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
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) {
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');
}
// =====================
// 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;