Sicherheits-Fixes, toter Code entfernt, Optimierungen
Sicherheit: - CSRF-Schutz auf allen API-Routes (admin, proposals, files, stats, export) - authenticateToken vor csrfProtection bei admin/proposals (CSRF-Bypass behoben) - CORS eingeschränkt auf taskmate.aegis-sight.de - JWT_SECRET und SESSION_TIMEOUT nicht mehr exportiert - Tote Auth-Funktionen entfernt (generateCsrfToken, generateToken Legacy) Toter Code entfernt: - 6 ungenutzte JS-Dateien (tour, dashboard, 4x contacts-*) - 2 ungenutzte CSS-Dateien (dashboard, contacts-extended) - backend/migrations/ Verzeichnis, knowledge.js.backup - Doppelter bcrypt require in database.js Optimierung: - Request-Logging filtert statische Assets (nur /api/ wird geloggt) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
103
frontend/js/auth-fix.js
Normale Datei
103
frontend/js/auth-fix.js
Normale Datei
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* TASKMATE - Auth Fix
|
||||
* ====================
|
||||
* Behebt Authentifizierungs-Probleme
|
||||
*/
|
||||
|
||||
// Überwache alle API-Anfragen
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
try {
|
||||
const response = await originalFetch.apply(this, args);
|
||||
|
||||
// Bei 401: Automatisches Token-Refresh versuchen
|
||||
if (response.status === 401 && !args[0].includes('/auth/login')) {
|
||||
console.log('[AuthFix] 401 erkannt, versuche Token-Refresh...');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Versuche Token-Refresh
|
||||
const refreshResponse = await originalFetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: refreshToken || token })
|
||||
});
|
||||
|
||||
if (refreshResponse.ok) {
|
||||
const data = await refreshResponse.json();
|
||||
console.log('[AuthFix] Token erfolgreich erneuert');
|
||||
|
||||
// Neue Tokens speichern
|
||||
localStorage.setItem('token', data.token);
|
||||
if (data.refreshToken) {
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
|
||||
// Original-Request mit neuem Token wiederholen
|
||||
if (args[1] && args[1].headers) {
|
||||
args[1].headers['Authorization'] = `Bearer ${data.token}`;
|
||||
}
|
||||
|
||||
return originalFetch.apply(this, args);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthFix] Token-Refresh fehlgeschlagen:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Stelle sicher, dass das Token bei jedem Request aktuell ist
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('[AuthFix] Auth-Fix geladen');
|
||||
|
||||
// Prüfe Token-Gültigkeit alle 5 Minuten
|
||||
setInterval(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
// Dekodiere JWT um Ablaufzeit zu prüfen
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const expiresIn = payload.exp * 1000 - Date.now();
|
||||
|
||||
// Wenn Token in weniger als 10 Minuten abläuft, erneuere es
|
||||
if (expiresIn < 10 * 60 * 1000) {
|
||||
console.log('[AuthFix] Token läuft bald ab, erneuere...');
|
||||
fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: localStorage.getItem('refreshToken') || token })
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
}).then(data => {
|
||||
if (data && data.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
if (data.refreshToken) {
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
console.log('[AuthFix] Token automatisch erneuert');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthFix] Fehler beim Token-Check:', error);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000); // Alle 5 Minuten
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
/**
|
||||
* TASKMATE - Dashboard Module
|
||||
* ===========================
|
||||
* Statistics and overview dashboard
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
import {
|
||||
$, $$, createElement, clearElement, formatDate, getDueDateStatus,
|
||||
getInitials
|
||||
} from './utils.js';
|
||||
|
||||
class DashboardManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
this.stats = null;
|
||||
this.completionData = null;
|
||||
this.timeData = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = $('#view-dashboard');
|
||||
|
||||
// Subscribe to store changes
|
||||
store.subscribe('currentView', (view) => {
|
||||
if (view === 'dashboard') this.loadAndRender();
|
||||
});
|
||||
|
||||
store.subscribe('currentProjectId', () => {
|
||||
if (store.get('currentView') === 'dashboard') {
|
||||
this.loadAndRender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// DATA LOADING
|
||||
// =====================
|
||||
|
||||
async loadAndRender() {
|
||||
if (store.get('currentView') !== 'dashboard') return;
|
||||
|
||||
store.setLoading(true);
|
||||
|
||||
try {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
const [stats, completionData, timeData] = await Promise.all([
|
||||
api.getStats(projectId),
|
||||
api.getCompletionStats(projectId, 8),
|
||||
api.getTimeStats(projectId)
|
||||
]);
|
||||
|
||||
this.stats = stats;
|
||||
this.completionData = completionData;
|
||||
this.timeData = timeData;
|
||||
// Due today tasks come from dashboard stats
|
||||
this.dueTodayTasks = stats.dueToday || [];
|
||||
// Overdue list - we only have the count, not individual tasks
|
||||
this.overdueTasks = [];
|
||||
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
this.showError('Fehler beim Laden der Dashboard-Daten');
|
||||
} finally {
|
||||
store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// RENDERING
|
||||
// =====================
|
||||
|
||||
render() {
|
||||
this.renderStats();
|
||||
this.renderCompletionChart();
|
||||
this.renderTimeChart();
|
||||
this.renderDueTodayList();
|
||||
this.renderOverdueList();
|
||||
}
|
||||
|
||||
renderStats() {
|
||||
if (!this.stats) return;
|
||||
|
||||
// Open tasks
|
||||
this.updateStatCard('stat-open', this.stats.open || 0);
|
||||
|
||||
// In progress
|
||||
this.updateStatCard('stat-progress', this.stats.inProgress || 0);
|
||||
|
||||
// Completed
|
||||
this.updateStatCard('stat-done', this.stats.completed || 0);
|
||||
|
||||
// Overdue
|
||||
this.updateStatCard('stat-overdue', this.stats.overdue || 0);
|
||||
}
|
||||
|
||||
updateStatCard(id, value) {
|
||||
const valueEl = $(`#${id}`);
|
||||
if (valueEl) {
|
||||
valueEl.textContent = value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
renderCompletionChart() {
|
||||
const container = $('#chart-completed');
|
||||
if (!container || !this.completionData) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
// Add bar-chart class
|
||||
container.classList.add('bar-chart');
|
||||
|
||||
const maxValue = Math.max(...this.completionData.map(d => d.count), 1);
|
||||
|
||||
this.completionData.forEach(item => {
|
||||
const percentage = (item.count / maxValue) * 100;
|
||||
|
||||
const barItem = createElement('div', { className: 'bar-item' }, [
|
||||
createElement('span', { className: 'bar-value' }, [item.count.toString()]),
|
||||
createElement('div', {
|
||||
className: 'bar',
|
||||
style: { height: `${Math.max(percentage, 5)}%` }
|
||||
}),
|
||||
createElement('span', { className: 'bar-label' }, [item.label || item.week])
|
||||
]);
|
||||
|
||||
container.appendChild(barItem);
|
||||
});
|
||||
}
|
||||
|
||||
renderTimeChart() {
|
||||
const container = $('#chart-time');
|
||||
if (!container || !this.timeData) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
// Add horizontal-bar-chart class
|
||||
container.classList.add('horizontal-bar-chart');
|
||||
|
||||
const totalTime = this.timeData.reduce((sum, item) => sum + (item.totalMinutes || 0), 0);
|
||||
|
||||
this.timeData.slice(0, 5).forEach(item => {
|
||||
const percentage = totalTime > 0 ? ((item.totalMinutes || 0) / totalTime) * 100 : 0;
|
||||
|
||||
const barItem = createElement('div', { className: 'horizontal-bar-item' }, [
|
||||
createElement('div', { className: 'horizontal-bar-header' }, [
|
||||
createElement('span', { className: 'horizontal-bar-label' }, [item.name || item.projectName]),
|
||||
createElement('span', { className: 'horizontal-bar-value' }, [
|
||||
this.formatMinutes(item.totalMinutes || 0)
|
||||
])
|
||||
]),
|
||||
createElement('div', { className: 'horizontal-bar' }, [
|
||||
createElement('div', {
|
||||
className: 'horizontal-bar-fill',
|
||||
style: { width: `${percentage}%` }
|
||||
})
|
||||
])
|
||||
]);
|
||||
|
||||
container.appendChild(barItem);
|
||||
});
|
||||
|
||||
if (this.timeData.length === 0) {
|
||||
container.appendChild(createElement('p', {
|
||||
className: 'text-secondary',
|
||||
style: { textAlign: 'center' }
|
||||
}, ['Keine Zeitdaten verfügbar']));
|
||||
}
|
||||
}
|
||||
|
||||
renderDueTodayList() {
|
||||
const container = $('#due-today-list');
|
||||
if (!container) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
if (!this.dueTodayTasks || this.dueTodayTasks.length === 0) {
|
||||
container.appendChild(createElement('p', {
|
||||
className: 'text-secondary empty-message'
|
||||
}, ['Keine Aufgaben für heute']));
|
||||
return;
|
||||
}
|
||||
|
||||
this.dueTodayTasks.slice(0, 5).forEach(task => {
|
||||
const hasAssignee = task.assignedTo || task.assignedName;
|
||||
const item = createElement('div', {
|
||||
className: 'due-today-item',
|
||||
onclick: () => this.openTaskModal(task.id)
|
||||
}, [
|
||||
createElement('span', {
|
||||
className: 'due-today-priority',
|
||||
style: { backgroundColor: this.getPriorityColor(task.priority) }
|
||||
}),
|
||||
createElement('span', { className: 'due-today-title' }, [task.title]),
|
||||
hasAssignee ? createElement('div', { className: 'due-today-assignee' }, [
|
||||
createElement('span', {
|
||||
className: 'avatar avatar-sm',
|
||||
style: { backgroundColor: task.assignedColor || '#888' }
|
||||
}, [getInitials(task.assignedName || 'U')])
|
||||
]) : null
|
||||
].filter(Boolean));
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
if (this.dueTodayTasks.length > 5) {
|
||||
container.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost btn-sm btn-block',
|
||||
onclick: () => this.showAllDueToday()
|
||||
}, [`Alle ${this.dueTodayTasks.length} anzeigen`]));
|
||||
}
|
||||
}
|
||||
|
||||
renderOverdueList() {
|
||||
const container = $('#overdue-list');
|
||||
if (!container) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
if (!this.overdueTasks || this.overdueTasks.length === 0) {
|
||||
container.appendChild(createElement('p', {
|
||||
className: 'text-secondary empty-message'
|
||||
}, ['Keine überfälligen Aufgaben']));
|
||||
return;
|
||||
}
|
||||
|
||||
this.overdueTasks.slice(0, 5).forEach(task => {
|
||||
const daysOverdue = this.getDaysOverdue(task.dueDate);
|
||||
|
||||
const item = createElement('div', {
|
||||
className: 'due-today-item overdue-item',
|
||||
onclick: () => this.openTaskModal(task.id)
|
||||
}, [
|
||||
createElement('span', {
|
||||
className: 'due-today-priority',
|
||||
style: { backgroundColor: this.getPriorityColor(task.priority) }
|
||||
}),
|
||||
createElement('div', { style: { flex: 1 } }, [
|
||||
createElement('span', { className: 'due-today-title' }, [task.title]),
|
||||
createElement('span', {
|
||||
className: 'text-error',
|
||||
style: { fontSize: 'var(--text-xs)', display: 'block' }
|
||||
}, [`${daysOverdue} Tag(e) überfällig`])
|
||||
]),
|
||||
task.assignee ? createElement('div', { className: 'due-today-assignee' }, [
|
||||
createElement('span', {
|
||||
className: 'avatar avatar-sm',
|
||||
style: { backgroundColor: task.assignee.color || '#888' }
|
||||
}, [task.assignee.initials || getInitials(task.assignee.username || '??')])
|
||||
]) : null
|
||||
].filter(Boolean));
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
if (this.overdueTasks.length > 5) {
|
||||
container.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost btn-sm btn-block',
|
||||
onclick: () => this.showAllOverdue()
|
||||
}, [`Alle ${this.overdueTasks.length} anzeigen`]));
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ACTIONS
|
||||
// =====================
|
||||
|
||||
openTaskModal(taskId) {
|
||||
window.dispatchEvent(new CustomEvent('modal:open', {
|
||||
detail: {
|
||||
modalId: 'task-modal',
|
||||
mode: 'edit',
|
||||
data: { taskId }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
showAllDueToday() {
|
||||
// Switch to list view with due date filter
|
||||
store.setFilter('dueDate', 'today');
|
||||
store.setCurrentView('list');
|
||||
}
|
||||
|
||||
showAllOverdue() {
|
||||
// Switch to list view with overdue filter
|
||||
store.setFilter('dueDate', 'overdue');
|
||||
store.setCurrentView('list');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
formatMinutes(minutes) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
getPriorityColor(priority) {
|
||||
const colors = {
|
||||
high: 'var(--priority-high)',
|
||||
medium: 'var(--priority-medium)',
|
||||
low: 'var(--priority-low)'
|
||||
};
|
||||
return colors[priority] || colors.medium;
|
||||
}
|
||||
|
||||
getDaysOverdue(dueDate) {
|
||||
const due = new Date(dueDate);
|
||||
const today = new Date();
|
||||
const diffTime = today - due;
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const dashboardManager = new DashboardManager();
|
||||
|
||||
export default dashboardManager;
|
||||
274
frontend/js/mobile-swipe.js
Normale Datei
274
frontend/js/mobile-swipe.js
Normale Datei
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* TASKMATE - Mobile Swipe Enhancement
|
||||
* ====================================
|
||||
* Neue Swipe-Funktionalität für bessere mobile Navigation
|
||||
*/
|
||||
|
||||
export function enhanceMobileSwipe(mobileManager) {
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
const SWIPE_VELOCITY = 0.3;
|
||||
|
||||
// State für Column-Navigation
|
||||
let currentColumnIndex = 0;
|
||||
let columnCount = 0;
|
||||
let isColumnSwipeEnabled = false;
|
||||
|
||||
// Column indicator elements
|
||||
let columnIndicator = null;
|
||||
|
||||
/**
|
||||
* Initialize column swipe for board view
|
||||
*/
|
||||
function initColumnSwipe() {
|
||||
const boardContainer = document.querySelector('.board-container');
|
||||
if (!boardContainer || mobileManager.currentView !== 'board') return;
|
||||
|
||||
// Create column indicator
|
||||
if (!columnIndicator) {
|
||||
columnIndicator = document.createElement('div');
|
||||
columnIndicator.className = 'mobile-column-indicator';
|
||||
columnIndicator.innerHTML = `
|
||||
<div class="column-dots"></div>
|
||||
<div class="column-name"></div>
|
||||
`;
|
||||
document.querySelector('.view-board')?.appendChild(columnIndicator);
|
||||
}
|
||||
|
||||
updateColumnInfo();
|
||||
showCurrentColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update column information
|
||||
*/
|
||||
function updateColumnInfo() {
|
||||
const columns = document.querySelectorAll('.column');
|
||||
columnCount = columns.length;
|
||||
|
||||
// Update dots
|
||||
const dotsContainer = columnIndicator?.querySelector('.column-dots');
|
||||
if (dotsContainer) {
|
||||
dotsContainer.innerHTML = Array.from({ length: columnCount }, (_, i) =>
|
||||
`<span class="dot ${i === currentColumnIndex ? 'active' : ''}"></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Update column name
|
||||
const nameContainer = columnIndicator?.querySelector('.column-name');
|
||||
if (nameContainer && columns[currentColumnIndex]) {
|
||||
const columnTitle = columns[currentColumnIndex].querySelector('.column-title')?.textContent || '';
|
||||
nameContainer.textContent = columnTitle;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show specific column (hide others)
|
||||
*/
|
||||
function showCurrentColumn() {
|
||||
const columns = document.querySelectorAll('.column');
|
||||
const boardContainer = document.querySelector('.board-container');
|
||||
|
||||
columns.forEach((col, index) => {
|
||||
if (index === currentColumnIndex) {
|
||||
col.style.display = 'flex';
|
||||
col.classList.add('mobile-active');
|
||||
} else {
|
||||
col.style.display = 'none';
|
||||
col.classList.remove('mobile-active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update add column button
|
||||
const addColumnBtn = document.querySelector('.btn-add-column');
|
||||
if (addColumnBtn) {
|
||||
addColumnBtn.style.display = currentColumnIndex === columnCount - 1 ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
updateColumnInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to specific column
|
||||
*/
|
||||
function navigateToColumn(index) {
|
||||
if (index < 0 || index >= columnCount) return;
|
||||
|
||||
currentColumnIndex = index;
|
||||
showCurrentColumn();
|
||||
|
||||
// Haptic feedback
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced board swipe handler
|
||||
*/
|
||||
mobileManager.handleBoardSwipeEnd = function() {
|
||||
if (!this.isSwiping || this.swipeDirection !== 'horizontal' || this.swipeTarget !== 'board') {
|
||||
this.resetSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||
const deltaTime = Date.now() - this.touchStartTime;
|
||||
const velocity = Math.abs(deltaX) / deltaTime;
|
||||
|
||||
const isValidSwipe = Math.abs(deltaX) > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY;
|
||||
|
||||
if (isValidSwipe && isColumnSwipeEnabled) {
|
||||
if (deltaX > 0 && currentColumnIndex > 0) {
|
||||
// Swipe right - previous column
|
||||
navigateToColumn(currentColumnIndex - 1);
|
||||
} else if (deltaX < 0 && currentColumnIndex < columnCount - 1) {
|
||||
// Swipe left - next column
|
||||
navigateToColumn(currentColumnIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.resetSwipe();
|
||||
};
|
||||
|
||||
/**
|
||||
* View hint for header swipes
|
||||
*/
|
||||
let viewHint = null;
|
||||
|
||||
function showViewSwipeHint(viewName, direction) {
|
||||
if (!viewHint) {
|
||||
viewHint = document.createElement('div');
|
||||
viewHint.className = 'mobile-view-hint';
|
||||
document.body.appendChild(viewHint);
|
||||
}
|
||||
|
||||
viewHint.textContent = getViewDisplayName(viewName);
|
||||
viewHint.classList.add('visible', direction);
|
||||
}
|
||||
|
||||
function hideViewSwipeHint() {
|
||||
if (viewHint) {
|
||||
viewHint.classList.remove('visible', 'left', 'right');
|
||||
}
|
||||
}
|
||||
|
||||
function getViewDisplayName(view) {
|
||||
const names = {
|
||||
'board': 'Board',
|
||||
'list': 'Liste',
|
||||
'calendar': 'Kalender',
|
||||
'proposals': 'Genehmigungen',
|
||||
'gitea': 'Gitea',
|
||||
'knowledge': 'Wissen'
|
||||
};
|
||||
return names[view] || view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced header swipe handler
|
||||
*/
|
||||
mobileManager.handleHeaderSwipeMove = function(e) {
|
||||
if (!this.isMobile || this.touchStartX === 0 || this.swipeTarget !== 'header') 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
|
||||
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;
|
||||
} else {
|
||||
this.swipeDirection = 'vertical';
|
||||
this.resetSwipe();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.swipeDirection !== 'horizontal') return;
|
||||
e.preventDefault();
|
||||
|
||||
// Show view hints
|
||||
const currentIndex = this.viewOrder.indexOf(this.currentView);
|
||||
if (deltaX > SWIPE_THRESHOLD && currentIndex > 0) {
|
||||
showViewSwipeHint(this.viewOrder[currentIndex - 1], 'left');
|
||||
} else if (deltaX < -SWIPE_THRESHOLD && currentIndex < this.viewOrder.length - 1) {
|
||||
showViewSwipeHint(this.viewOrder[currentIndex + 1], 'right');
|
||||
} else {
|
||||
hideViewSwipeHint();
|
||||
}
|
||||
};
|
||||
|
||||
mobileManager.handleHeaderSwipeEnd = function() {
|
||||
if (!this.isSwiping || this.swipeDirection !== 'horizontal' || this.swipeTarget !== 'header') {
|
||||
this.resetSwipe();
|
||||
hideViewSwipeHint();
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||
const deltaTime = Date.now() - this.touchStartTime;
|
||||
const velocity = Math.abs(deltaX) / deltaTime;
|
||||
|
||||
const isValidSwipe = Math.abs(deltaX) > SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY;
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
hideViewSwipeHint();
|
||||
this.resetSwipe();
|
||||
};
|
||||
|
||||
// Listen for view changes
|
||||
document.addEventListener('view:changed', (e) => {
|
||||
if (e.detail?.view === 'board' && mobileManager.isMobile) {
|
||||
isColumnSwipeEnabled = true;
|
||||
setTimeout(initColumnSwipe, 100);
|
||||
} else {
|
||||
isColumnSwipeEnabled = false;
|
||||
if (columnIndicator) {
|
||||
columnIndicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for column updates
|
||||
document.addEventListener('columns:updated', () => {
|
||||
if (isColumnSwipeEnabled) {
|
||||
updateColumnInfo();
|
||||
showCurrentColumn();
|
||||
}
|
||||
});
|
||||
|
||||
// Update on resize
|
||||
window.addEventListener('resize', () => {
|
||||
if (mobileManager.isMobile && mobileManager.currentView === 'board') {
|
||||
if (!isColumnSwipeEnabled) {
|
||||
isColumnSwipeEnabled = true;
|
||||
initColumnSwipe();
|
||||
}
|
||||
} else {
|
||||
isColumnSwipeEnabled = false;
|
||||
// Show all columns on desktop
|
||||
document.querySelectorAll('.column').forEach(col => {
|
||||
col.style.display = '';
|
||||
col.classList.remove('mobile-active');
|
||||
});
|
||||
if (columnIndicator) {
|
||||
columnIndicator.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
/**
|
||||
* TASKMATE - Tour/Onboarding Module
|
||||
* ==================================
|
||||
* First-time user onboarding tour
|
||||
*/
|
||||
|
||||
import { $, createElement } from './utils.js';
|
||||
|
||||
class TourManager {
|
||||
constructor() {
|
||||
this.currentStep = 0;
|
||||
this.isActive = false;
|
||||
this.overlay = null;
|
||||
this.tooltip = null;
|
||||
|
||||
this.steps = [
|
||||
{
|
||||
target: '.view-tabs',
|
||||
title: 'Ansichten',
|
||||
content: 'Wechseln Sie zwischen Board-, Listen-, Kalender- und Dashboard-Ansicht.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.project-selector',
|
||||
title: 'Projekte',
|
||||
content: 'Wählen Sie ein Projekt aus oder erstellen Sie ein neues.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.column',
|
||||
title: 'Spalten',
|
||||
content: 'Spalten repräsentieren den Status Ihrer Aufgaben. Ziehen Sie Aufgaben zwischen Spalten, um den Status zu ändern.',
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
target: '.btn-add-task',
|
||||
title: 'Neue Aufgabe',
|
||||
content: 'Klicken Sie hier, um eine neue Aufgabe zu erstellen.',
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
target: '.filter-bar',
|
||||
title: 'Filter',
|
||||
content: 'Filtern Sie Aufgaben nach Priorität, Bearbeiter oder Fälligkeitsdatum.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '#search-input',
|
||||
title: 'Suche',
|
||||
content: 'Durchsuchen Sie alle Aufgaben nach Titel oder Beschreibung. Tipp: Drücken Sie "/" für schnellen Zugriff.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.user-menu',
|
||||
title: 'Benutzermenu',
|
||||
content: 'Hier können Sie Ihr Passwort ändern oder sich abmelden.',
|
||||
position: 'bottom-left'
|
||||
},
|
||||
{
|
||||
target: '#theme-toggle',
|
||||
title: 'Design',
|
||||
content: 'Wechseln Sie zwischen hellem und dunklem Design.',
|
||||
position: 'bottom-left'
|
||||
}
|
||||
];
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
window.addEventListener('tour:start', () => this.start());
|
||||
window.addEventListener('tour:stop', () => this.stop());
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.isActive) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
this.stop();
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
|
||||
this.next();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.previous();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TOUR CONTROL
|
||||
// =====================
|
||||
|
||||
start() {
|
||||
// Check if tour was already completed
|
||||
if (localStorage.getItem('tour_completed') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isActive = true;
|
||||
this.currentStep = 0;
|
||||
|
||||
this.createOverlay();
|
||||
this.showStep();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isActive = false;
|
||||
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = null;
|
||||
}
|
||||
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
|
||||
// Remove highlight from any element
|
||||
$$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight'));
|
||||
}
|
||||
|
||||
complete() {
|
||||
localStorage.setItem('tour_completed', 'true');
|
||||
this.stop();
|
||||
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: {
|
||||
message: 'Tour abgeschlossen! Viel Erfolg mit TaskMate.',
|
||||
type: 'success'
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.currentStep < this.steps.length - 1) {
|
||||
this.currentStep++;
|
||||
this.showStep();
|
||||
} else {
|
||||
this.complete();
|
||||
}
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.currentStep > 0) {
|
||||
this.currentStep--;
|
||||
this.showStep();
|
||||
}
|
||||
}
|
||||
|
||||
skip() {
|
||||
localStorage.setItem('tour_completed', 'true');
|
||||
this.stop();
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UI CREATION
|
||||
// =====================
|
||||
|
||||
createOverlay() {
|
||||
this.overlay = createElement('div', {
|
||||
className: 'onboarding-overlay'
|
||||
});
|
||||
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
showStep() {
|
||||
const step = this.steps[this.currentStep];
|
||||
const targetElement = $(step.target);
|
||||
|
||||
if (!targetElement) {
|
||||
// Skip to next step if target not found
|
||||
this.next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove previous highlight
|
||||
$$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight'));
|
||||
|
||||
// Highlight current target
|
||||
targetElement.classList.add('tour-highlight');
|
||||
|
||||
// Position and show tooltip
|
||||
this.showTooltip(step, targetElement);
|
||||
}
|
||||
|
||||
showTooltip(step, targetElement) {
|
||||
// Remove existing tooltip
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
}
|
||||
|
||||
// Create tooltip
|
||||
this.tooltip = createElement('div', {
|
||||
className: 'onboarding-tooltip'
|
||||
});
|
||||
|
||||
// Content
|
||||
const content = createElement('div', { className: 'onboarding-content' }, [
|
||||
createElement('h3', {}, [step.title]),
|
||||
createElement('p', {}, [step.content])
|
||||
]);
|
||||
this.tooltip.appendChild(content);
|
||||
|
||||
// Footer
|
||||
const footer = createElement('div', { className: 'onboarding-footer' });
|
||||
|
||||
// Step indicator
|
||||
footer.appendChild(createElement('span', {
|
||||
id: 'onboarding-step'
|
||||
}, [`${this.currentStep + 1} / ${this.steps.length}`]));
|
||||
|
||||
// Buttons
|
||||
const buttons = createElement('div', { className: 'onboarding-buttons' });
|
||||
|
||||
if (this.currentStep > 0) {
|
||||
buttons.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost',
|
||||
onclick: () => this.previous()
|
||||
}, ['Zurück']));
|
||||
}
|
||||
|
||||
buttons.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost',
|
||||
onclick: () => this.skip()
|
||||
}, ['Überspringen']));
|
||||
|
||||
const isLast = this.currentStep === this.steps.length - 1;
|
||||
buttons.appendChild(createElement('button', {
|
||||
className: 'btn btn-primary',
|
||||
onclick: () => this.next()
|
||||
}, [isLast ? 'Fertig' : 'Weiter']));
|
||||
|
||||
footer.appendChild(buttons);
|
||||
this.tooltip.appendChild(footer);
|
||||
|
||||
document.body.appendChild(this.tooltip);
|
||||
|
||||
// Position tooltip
|
||||
this.positionTooltip(targetElement, step.position);
|
||||
}
|
||||
|
||||
positionTooltip(targetElement, position) {
|
||||
const targetRect = targetElement.getBoundingClientRect();
|
||||
const tooltipRect = this.tooltip.getBoundingClientRect();
|
||||
|
||||
const padding = 16;
|
||||
let top, left;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = targetRect.top - tooltipRect.height - padding;
|
||||
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
|
||||
case 'bottom':
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
|
||||
case 'left':
|
||||
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
|
||||
left = targetRect.left - tooltipRect.width - padding;
|
||||
break;
|
||||
|
||||
case 'right':
|
||||
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
|
||||
left = targetRect.right + padding;
|
||||
break;
|
||||
|
||||
case 'bottom-left':
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.right - tooltipRect.width;
|
||||
break;
|
||||
|
||||
case 'bottom-right':
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left;
|
||||
break;
|
||||
|
||||
default:
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left;
|
||||
}
|
||||
|
||||
// Keep within viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (left < padding) left = padding;
|
||||
if (left + tooltipRect.width > viewportWidth - padding) {
|
||||
left = viewportWidth - tooltipRect.width - padding;
|
||||
}
|
||||
|
||||
if (top < padding) top = padding;
|
||||
if (top + tooltipRect.height > viewportHeight - padding) {
|
||||
top = viewportHeight - tooltipRect.height - padding;
|
||||
}
|
||||
|
||||
this.tooltip.style.top = `${top}px`;
|
||||
this.tooltip.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
shouldShowTour() {
|
||||
return localStorage.getItem('tour_completed') !== 'true';
|
||||
}
|
||||
|
||||
resetTour() {
|
||||
localStorage.removeItem('tour_completed');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for querying multiple elements
|
||||
function $$(selector) {
|
||||
return Array.from(document.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const tourManager = new TourManager();
|
||||
|
||||
export default tourManager;
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren