Files
TaskMate/frontend/js/app.js
2026-01-04 00:24:11 +00:00

1542 Zeilen
44 KiB
JavaScript
Originalformat Blame Verlauf

Diese Datei enthält mehrdeutige Unicode-Zeichen

Diese Datei enthält Unicode-Zeichen, die mit anderen Zeichen verwechselt werden können. Wenn du glaubst, dass das absichtlich so ist, kannst du diese Warnung ignorieren. Benutze den „Escape“-Button, um versteckte Zeichen anzuzeigen.

/**
* TASKMATE - Main Application
* ===========================
* Entry point and initialization
*/
import store from './store.js';
import api from './api.js';
import authManager from './auth.js';
import syncManager from './sync.js';
import offlineManager from './offline.js';
import boardManager from './board.js';
import taskModalManager from './task-modal.js';
import calendarViewManager from './calendar.js';
import listViewManager from './list.js';
import shortcutsManager from './shortcuts.js';
import undoManager from './undo.js';
// Tour/Tutorial entfernt
import adminManager from './admin.js';
import proposalsManager from './proposals.js';
import notificationManager from './notifications.js';
import giteaManager from './gitea.js';
import knowledgeManager from './knowledge.js';
import codingManager from './coding.js';
import mobileManager from './mobile.js';
import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js';
class App {
constructor() {
this.isInitialized = false;
}
// =====================
// INITIALIZATION
// =====================
async init() {
console.log('[App] Initializing...');
// Initialize offline storage
await offlineManager.init();
// Check authentication
const isAuthenticated = await authManager.init();
if (isAuthenticated) {
// Check if user is admin
if (authManager.isAdmin()) {
await this.initializeAdminApp();
} else {
await this.initializeApp();
}
} else {
this.showLoginScreen();
}
// Bind global events
this.bindGlobalEvents();
// Register service worker
this.registerServiceWorker();
console.log('[App] Initialized');
this.isInitialized = true;
}
async initializeApp() {
this.showAppScreen();
// Connect to WebSocket
syncManager.connect();
// Load initial data
await this.loadInitialData();
// Initialize proposals manager
await proposalsManager.init();
// Initialize notification manager
await notificationManager.init();
// Initialize gitea manager
await giteaManager.init();
// Initialize coding manager
await codingManager.init();
// Initialize knowledge manager
await knowledgeManager.init();
// Initialize mobile features
mobileManager.init();
// Update UI
this.updateUserMenu();
// Dispatch event for mobile menu
document.dispatchEvent(new CustomEvent('projects:loaded'));
}
async initializeAdminApp() {
// Initialize admin manager FIRST (loads DOM elements)
await adminManager.init();
// Then show the screen
this.showAdminScreen();
}
// =====================
// DATA LOADING
// =====================
async loadInitialData() {
store.setLoading(true);
try {
// Load users
const users = await api.getUsers();
store.setUsers(users);
// Load projects
const projects = await api.getProjects();
store.setProjects(projects);
// Set current project
let currentProjectId = getFromStorage('current_project_id');
if (!currentProjectId && projects.length > 0) {
currentProjectId = projects[0].id;
}
if (currentProjectId) {
await this.loadProject(currentProjectId);
}
// Populate project selector
this.populateProjectSelector();
} catch (error) {
console.error('[App] Failed to load initial data:', error);
// Try loading from offline cache
if (!navigator.onLine || error.isOffline) {
await this.loadFromCache();
} else {
this.showError('Fehler beim Laden der Daten');
}
} finally {
store.setLoading(false);
}
}
async loadProject(projectId) {
store.setCurrentProject(projectId);
try {
const [columns, tasks, labels] = await Promise.all([
api.getColumns(projectId),
api.getTasks(projectId),
api.getLabels(projectId)
]);
store.setColumns(columns);
store.setTasks(tasks);
store.setLabels(labels);
// Update project selector
const projectSelect = $('#project-select');
if (projectSelect) {
projectSelect.value = projectId;
}
} catch (error) {
console.error('[App] Failed to load project:', error);
if (!navigator.onLine || error.isOffline) {
await offlineManager.loadOfflineData();
} else {
throw error;
}
}
}
async loadFromCache() {
console.log('[App] Loading from offline cache');
const projects = await offlineManager.getCachedProjects();
store.setProjects(projects);
const currentProjectId = getFromStorage('current_project_id');
if (currentProjectId) {
store.setCurrentProject(currentProjectId);
await offlineManager.loadOfflineData();
}
this.populateProjectSelector();
store.setSyncStatus('offline');
}
// =====================
// UI SCREENS
// =====================
showLoginScreen() {
$('#login-screen')?.classList.remove('hidden');
$('#app-screen')?.classList.add('hidden');
$('#admin-screen')?.classList.remove('active');
}
showAppScreen() {
$('#login-screen')?.classList.add('hidden');
$('#app-screen')?.classList.remove('hidden');
$('#admin-screen')?.classList.remove('active');
}
showAdminScreen() {
$('#login-screen')?.classList.add('hidden');
$('#app-screen')?.classList.add('hidden');
adminManager.show();
}
// =====================
// EVENT BINDING
// =====================
bindGlobalEvents() {
// Auth events
window.addEventListener('auth:login', () => this.handleLogin());
window.addEventListener('auth:logout', () => this.handleLogout());
// View switching
$$('.view-tab')?.forEach(tab => {
tab.addEventListener('click', () => {
const view = tab.dataset.view;
this.switchView(view);
});
});
// Project selector
$('#project-select')?.addEventListener('change', (e) => {
this.loadProject(parseInt(e.target.value));
});
// Add project button
$('#btn-new-project')?.addEventListener('click', () => {
this.openProjectModal('create');
});
// Edit project button
$('#btn-edit-project')?.addEventListener('click', () => {
const currentProject = store.getCurrentProject();
if (currentProject) {
this.openProjectModal('edit', currentProject);
} else {
this.showError('Kein Projekt ausgewählt');
}
});
// Search - Hybrid search with client-side filtering and server search
this.setupSearch();
// Filter changes
$('#filter-priority')?.addEventListener('change', (e) => {
store.setFilter('priority', e.target.value);
});
$('#filter-assignee')?.addEventListener('change', (e) => {
store.setFilter('assignee', e.target.value);
});
$('#filter-labels')?.addEventListener('change', (e) => {
store.setFilter('label', e.target.value);
});
$('#filter-due')?.addEventListener('change', (e) => {
store.setFilter('dueDate', e.target.value);
});
// Reset filters
$('#btn-reset-filters')?.addEventListener('click', () => {
store.resetFilters();
this.resetFilterInputs();
});
// Open archive modal
$('#btn-show-archived')?.addEventListener('click', () => {
this.openArchiveModal();
});
// Modal events
window.addEventListener('modal:open', (e) => this.handleModalOpen(e.detail));
window.addEventListener('modal:close', (e) => this.handleModalClose(e.detail));
// Toast events
window.addEventListener('toast:show', (e) => this.showToast(e.detail));
// Confirm dialog events
window.addEventListener('confirm:show', (e) => this.showConfirmDialog(e.detail));
// Lightbox events
window.addEventListener('lightbox:open', (e) => this.openLightbox(e.detail));
// App refresh
window.addEventListener('app:refresh', () => this.refresh());
// Notification navigation - open task from inbox
window.addEventListener('notification:open-task', (e) => {
const taskId = e.detail.taskId;
if (taskId) {
// Switch to board view first
this.switchView('board');
// Open task modal
setTimeout(() => {
window.dispatchEvent(new CustomEvent('modal:open', {
detail: { modalId: 'task-modal', mode: 'edit', data: { taskId } }
}));
}, 100);
}
});
// Notification navigation - open proposal from inbox
window.addEventListener('notification:open-proposal', (e) => {
const proposalId = e.detail.proposalId;
if (proposalId) {
// Switch to proposals view
this.switchView('proposals');
// Highlight the proposal after view is loaded
setTimeout(() => {
proposalsManager.scrollToAndHighlight(proposalId);
}, 300);
}
});
// Online/Offline
window.addEventListener('online', () => this.handleOnline());
window.addEventListener('offline', () => this.handleOffline());
// Mobile events
document.addEventListener('project:selected', (e) => {
const projectId = e.detail?.projectId;
if (projectId) {
this.loadProject(projectId);
}
});
document.addEventListener('auth:logout', () => {
authManager.logout();
});
document.addEventListener('admin:open', () => {
// Redirect to admin screen for admins
if (authManager.isAdmin()) {
this.showAdminScreen();
}
});
document.addEventListener('task:move', async (e) => {
const { taskId, columnId, position } = e.detail;
if (taskId && columnId !== undefined) {
await boardManager.moveTask(taskId, columnId, position);
}
});
// Close modal on overlay click
$('.modal-overlay')?.addEventListener('click', () => {
// Check if task-modal is open - let it handle its own close (with auto-save)
if (store.isModalOpen('task-modal')) {
window.dispatchEvent(new CustomEvent('modal:close', {
detail: { modalId: 'task-modal' }
}));
} else {
store.closeAllModals();
this.hideAllModals();
}
});
// Column modal form
$('#column-form')?.addEventListener('submit', (e) => this.handleColumnFormSubmit(e));
// Column filter category dropdown - show/hide custom filter input
$('#column-filter-category')?.addEventListener('change', (e) => {
const customGroup = $('#column-custom-filter-group');
if (customGroup) {
customGroup.classList.toggle('hidden', e.target.value !== 'custom');
}
});
// Project modal form
$('#project-form')?.addEventListener('submit', (e) => this.handleProjectFormSubmit(e));
// Delete project button
$('#btn-delete-project')?.addEventListener('click', () => this.handleDeleteProject());
// Label modal form
$('#label-form')?.addEventListener('submit', (e) => this.handleLabelFormSubmit(e));
// Export button
$('#btn-export')?.addEventListener('click', () => this.exportProject());
// Import button
$('#btn-import')?.addEventListener('click', () => this.importProject());
// User menu toggle
$('#user-menu-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleUserMenu();
});
// Close user menu when clicking outside
document.addEventListener('click', (e) => {
const userMenu = $('.user-menu');
if (userMenu && !userMenu.contains(e.target)) {
$('#user-dropdown')?.classList.add('hidden');
}
});
// Logout button
$('#btn-logout')?.addEventListener('click', () => {
authManager.logout();
});
// Settings button
$('#btn-settings')?.addEventListener('click', () => {
this.handleModalOpen({ modalId: 'settings-modal' });
$('#user-dropdown')?.classList.add('hidden');
this.initColorPicker();
});
// Color picker options (preset colors)
$('#user-color-options')?.addEventListener('click', (e) => {
const colorOption = e.target.closest('.color-option');
if (colorOption) {
this.handleColorSelect(colorOption.dataset.color);
}
});
// Color picker custom color input - use 'change' to only save when selection is complete
$('#user-color-custom')?.addEventListener('change', (e) => {
this.handleColorSelect(e.target.value);
});
// Also update preview in real-time while selecting (without saving)
$('#user-color-custom')?.addEventListener('input', (e) => {
const preview = $('#user-color-preview');
if (preview) {
preview.style.backgroundColor = e.target.value;
}
});
// Modal close buttons (data-close-modal)
$$('[data-close-modal]')?.forEach(btn => {
btn.addEventListener('click', () => {
const modal = btn.closest('.modal');
if (modal) {
this.handleModalClose({ modalId: modal.id });
}
});
});
// Change password form
$('#change-password-form')?.addEventListener('submit', (e) => this.handleChangePassword(e));
}
// =====================
// PASSWORD CHANGE
// =====================
async handleChangePassword(e) {
e.preventDefault();
const currentPassword = $('#current-password')?.value;
const newPassword = $('#new-password')?.value;
const confirmPassword = $('#confirm-password')?.value;
const errorEl = $('#password-error');
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
this.showPasswordError('Bitte alle Felder ausfüllen.');
return;
}
if (newPassword.length < 8) {
this.showPasswordError('Das neue Passwort muss mindestens 8 Zeichen lang sein.');
return;
}
if (newPassword !== confirmPassword) {
this.showPasswordError('Die Passwörter stimmen nicht überein.');
return;
}
try {
await authManager.changePassword(currentPassword, newPassword);
// Success
this.handleModalClose({ modalId: 'settings-modal' });
$('#change-password-form')?.reset();
if (errorEl) errorEl.classList.add('hidden');
this.showSuccess('Passwort erfolgreich geändert');
} catch (error) {
this.showPasswordError(error.message || 'Fehler beim Ändern des Passworts.');
}
}
showPasswordError(message) {
const errorEl = $('#password-error');
if (errorEl) {
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
}
// =====================
// COLOR PICKER
// =====================
initColorPicker() {
const currentUser = authManager.getUser();
const currentColor = currentUser?.color || authManager.getUserColor();
// Update preview
const preview = $('#user-color-preview');
const previewInitial = $('#user-color-preview-initial');
if (preview) {
preview.style.backgroundColor = currentColor;
}
if (previewInitial && currentUser) {
previewInitial.textContent = currentUser.username || 'U';
}
// Update custom color picker
const customColorInput = $('#user-color-custom');
if (customColorInput) {
customColorInput.value = currentColor;
}
// Mark selected color (if it matches a preset)
$$('.color-option').forEach(option => {
option.classList.toggle('selected', option.dataset.color.toUpperCase() === currentColor.toUpperCase());
});
}
async handleColorSelect(color) {
if (!color) return;
// Update preview immediately
const preview = $('#user-color-preview');
if (preview) {
preview.style.backgroundColor = color;
}
// Update custom color picker
const customColorInput = $('#user-color-custom');
if (customColorInput) {
customColorInput.value = color;
}
// Update selected state for presets
$$('.color-option').forEach(option => {
option.classList.toggle('selected', option.dataset.color.toUpperCase() === color.toUpperCase());
});
try {
// Save to backend
await api.updateUserColor(color);
// Update authManager (also updates localStorage)
authManager.updateUserColor(color);
// Get current user from authManager
const currentUser = authManager.getUser();
// Update users list in store
if (currentUser) {
const users = store.get('users');
const updatedUsers = users.map(u =>
u.id === currentUser.id ? { ...u, color } : u
);
store.setUsers(updatedUsers);
}
// Update user avatar in header
const userAvatar = $('#user-menu-btn');
if (userAvatar) {
userAvatar.style.backgroundColor = color;
}
// Re-render board to update task cards with new color
boardManager.render();
// Re-render calendar if active
if (store.get('currentView') === 'calendar') {
calendarViewManager.render();
}
this.showSuccess('Farbe erfolgreich geaendert');
} catch (error) {
this.showError(error.message || 'Fehler beim Speichern der Farbe');
}
}
// =====================
// AUTH HANDLERS
// =====================
async handleLogin() {
// Check if user is admin
if (authManager.isAdmin()) {
await this.initializeAdminApp();
} else {
await this.initializeApp();
}
}
handleLogout() {
store.reset();
syncManager.disconnect();
adminManager.hide();
notificationManager.reset();
this.showLoginScreen();
}
// =====================
// VIEW SWITCHING
// =====================
switchView(view) {
store.setCurrentView(view);
// Update tabs
$$('.view-tab')?.forEach(tab => {
tab.classList.toggle('active', tab.dataset.view === view);
});
// Show/hide views
$$('.view')?.forEach(v => {
const isActive = v.id === `view-${view}`;
v.classList.toggle('active', isActive);
v.classList.toggle('hidden', !isActive);
});
// Clear search field when switching views
const searchInput = $('#search-input');
if (searchInput && searchInput.value) {
searchInput.value = '';
store.setFilter('search', '');
store.setState({ searchResultIds: [] }, 'CLEAR_SEARCH_RESULTS');
proposalsManager.setSearchQuery('');
knowledgeManager.setSearchQuery('');
$('#search-clear')?.classList.add('hidden');
$('.search-container')?.classList.remove('has-search');
}
// Load proposals when switching to proposals view - reset to active (non-archived)
if (view === 'proposals') {
proposalsManager.resetToActiveView();
}
// Show/hide coding manager
if (view === 'coding') {
codingManager.show();
} else {
codingManager.hide();
}
// Show/hide knowledge manager
if (view === 'knowledge') {
knowledgeManager.show();
} else {
knowledgeManager.hide();
}
}
// =====================
// PROJECT SELECTOR
// =====================
populateProjectSelector() {
const select = $('#project-select');
if (!select) return;
const projects = store.get('projects');
select.innerHTML = '';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
// Set current value
const currentProjectId = store.get('currentProjectId');
if (currentProjectId) {
select.value = currentProjectId;
}
// Also populate filter assignee
this.populateAssigneeFilter();
this.populateLabelFilter();
}
populateAssigneeFilter() {
const select = $('#filter-assignee');
if (!select) return;
const users = store.get('users');
select.innerHTML = '<option value="all">Alle Bearbeiter</option>';
users.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.username;
select.appendChild(option);
});
}
populateLabelFilter() {
const select = $('#filter-labels');
if (!select) return;
const labels = store.get('labels');
select.innerHTML = '<option value="all">Alle Labels</option>';
labels.forEach(label => {
const option = document.createElement('option');
option.value = label.id;
option.textContent = label.name;
select.appendChild(option);
});
}
resetFilterInputs() {
const priority = $('#filter-priority');
const assignee = $('#filter-assignee');
const labels = $('#filter-labels');
const dueDate = $('#filter-due');
const search = $('#search-input');
const searchClear = $('#search-clear');
const searchContainer = $('.search-container');
if (priority) priority.value = 'all';
if (assignee) assignee.value = 'all';
if (labels) labels.value = 'all';
if (dueDate) dueDate.value = '';
if (search) search.value = '';
if (searchClear) searchClear.classList.add('hidden');
if (searchContainer) searchContainer.classList.remove('has-search');
}
openArchiveModal() {
this.handleModalOpen({ modalId: 'archive-modal' });
this.renderArchiveList();
}
renderArchiveList() {
const archiveList = $('#archive-list');
const archiveEmpty = $('#archive-empty');
if (!archiveList) return;
// Get all archived tasks
const tasks = store.get('tasks').filter(t => t.archived);
const columns = store.get('columns');
if (tasks.length === 0) {
archiveList.classList.add('hidden');
archiveEmpty?.classList.remove('hidden');
return;
}
archiveList.classList.remove('hidden');
archiveEmpty?.classList.add('hidden');
// Sort by archived date (most recent first) - use updatedAt as proxy
tasks.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
const priorityLabels = { high: 'Hoch', medium: 'Mittel', low: 'Niedrig' };
archiveList.innerHTML = tasks.map(task => {
const column = columns.find(c => c.id === task.columnId);
const columnName = column?.name || 'Unbekannt';
return `
<div class="archive-item" data-task-id="${task.id}">
<div class="archive-item-info" onclick="app.openArchivedTask('${task.id}')">
<div class="archive-item-title">${this.escapeHtml(task.title)}</div>
<div class="archive-item-meta">
<span class="priority-badge ${task.priority || 'medium'}">${priorityLabels[task.priority] || 'Mittel'}</span>
<span>Spalte: ${this.escapeHtml(columnName)}</span>
${task.dueDate ? `<span>Fällig: ${new Date(task.dueDate).toLocaleDateString('de-DE')}</span>` : ''}
</div>
</div>
<div class="archive-item-actions">
<button class="btn btn-sm btn-primary" onclick="app.restoreTask('${task.id}')">Wiederherstellen</button>
<button class="btn btn-sm btn-text text-danger" onclick="app.deleteArchivedTask('${task.id}')">Löschen</button>
</div>
</div>
`;
}).join('');
}
async restoreTask(taskId) {
try {
const projectId = store.get('currentProjectId');
await api.restoreTask(projectId, taskId);
store.updateTask(taskId, { archived: false });
this.renderArchiveList();
this.showSuccess('Aufgabe wiederhergestellt');
// Re-render board
if (this.board) {
this.board.render();
}
} catch (error) {
console.error('Restore error:', error);
this.showError('Fehler beim Wiederherstellen');
}
}
openArchivedTask(taskId) {
// Close archive modal first
this.handleModalClose({ modalId: 'archive-modal' });
// Open task modal
if (this.taskModal) {
this.taskModal.open(taskId);
}
}
async deleteArchivedTask(taskId) {
window.dispatchEvent(new CustomEvent('confirm:show', {
detail: {
message: 'Möchten Sie diese Aufgabe endgültig löschen?',
confirmText: 'Löschen',
confirmClass: 'btn-danger',
onConfirm: async () => {
try {
const projectId = store.get('currentProjectId');
await api.deleteTask(projectId, taskId);
store.removeTask(taskId);
this.renderArchiveList();
this.showSuccess('Aufgabe gelöscht');
} catch (error) {
this.showError('Fehler beim Löschen');
}
}
}
}));
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// =====================
// SEARCH
// =====================
setupSearch() {
const searchInput = $('#search-input');
const searchClear = $('#search-clear');
const searchSpinner = $('#search-spinner');
const searchContainer = $('.search-container');
if (!searchInput) return;
// Abort controller for canceling pending server requests
let searchAbortController = null;
// Update UI based on search state
const updateSearchUI = (value) => {
const hasValue = value && value.trim().length > 0;
searchClear?.classList.toggle('hidden', !hasValue);
searchContainer?.classList.toggle('has-search', hasValue);
};
// Clear search function
const clearSearch = () => {
searchInput.value = '';
store.setFilter('search', '');
store.setState({ searchResultIds: [] }, 'CLEAR_SEARCH_RESULTS');
updateSearchUI('');
searchInput.focus();
// Clear view-specific search
proposalsManager.setSearchQuery('');
knowledgeManager.setSearchQuery('');
// Cancel any pending server search
if (searchAbortController) {
searchAbortController.abort();
searchAbortController = null;
}
searchSpinner?.classList.add('hidden');
};
// Perform server search (for deep search in subtasks, links, etc.)
const performServerSearch = debounce(async (query) => {
if (!query || query.trim().length < 2) {
searchSpinner?.classList.add('hidden');
// Clear server search results when query is too short
store.setState({ searchResultIds: [] }, 'CLEAR_SEARCH_RESULTS');
return;
}
const projectId = store.get('currentProjectId');
if (!projectId) {
searchSpinner?.classList.add('hidden');
return;
}
// Cancel previous request
if (searchAbortController) {
searchAbortController.abort();
}
searchAbortController = new AbortController();
try {
searchSpinner?.classList.remove('hidden');
const results = await api.searchTasks(projectId, query);
// Store the IDs of tasks found by server search
// These will bypass the client-side search filter
const serverResultIds = results.map(t => t.id);
store.setState({ searchResultIds: serverResultIds }, 'SET_SEARCH_RESULTS');
// Merge server results with current tasks (add any that aren't already loaded)
const currentTasks = store.get('tasks');
const currentTaskIds = new Set(currentTasks.map(t => t.id));
const newTasks = results.filter(t => !currentTaskIds.has(t.id));
if (newTasks.length > 0) {
// Add newly found tasks to the store
store.setState({
tasks: [...currentTasks, ...newTasks]
}, 'SEARCH_RESULTS_MERGED');
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Server search failed:', error);
}
} finally {
searchSpinner?.classList.add('hidden');
searchAbortController = null;
}
}, 500);
// Input event - immediate client-side filter + delayed server search
searchInput.addEventListener('input', (e) => {
const value = e.target.value;
updateSearchUI(value);
// Check current view
const currentView = store.get('currentView');
if (currentView === 'proposals') {
// Search proposals only
proposalsManager.setSearchQuery(value);
} else if (currentView === 'knowledge') {
// Search knowledge base
knowledgeManager.setSearchQuery(value);
} else {
// Immediate client-side filtering for tasks
store.setFilter('search', value);
// Delayed server search for deep content (subtasks, links, attachments, comments)
performServerSearch(value);
}
});
// Clear button click
searchClear?.addEventListener('click', clearSearch);
// Escape key to clear search
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
clearSearch();
}
});
// Global Escape key handler (when search input is not focused)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && searchInput.value && document.activeElement !== searchInput) {
clearSearch();
}
});
// Initialize UI state (in case there's a value from before)
updateSearchUI(searchInput.value);
}
// =====================
// USER MENU
// =====================
updateUserMenu() {
const user = authManager.getUser();
if (!user) return;
const avatar = $('#user-initial');
if (avatar) {
avatar.textContent = authManager.getUserInitials();
}
const avatarBtn = $('#user-menu-btn');
if (avatarBtn) {
avatarBtn.style.backgroundColor = authManager.getUserColor();
}
const userName = $('#user-name');
if (userName) userName.textContent = user.username;
const userRole = $('#user-role');
if (userRole) userRole.textContent = user.role === 'admin' ? 'Administrator' : 'Benutzer';
// Notify mobile menu
document.dispatchEvent(new CustomEvent('user:updated'));
}
toggleUserMenu() {
const dropdown = $('#user-dropdown');
if (dropdown) {
dropdown.classList.toggle('hidden');
}
}
// =====================
// MODALS
// =====================
handleModalOpen(detail) {
const { modalId, mode, data } = detail;
const modal = $(`#${modalId}`);
const overlay = $('.modal-overlay');
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('visible');
modal.dataset.mode = mode || 'create';
if (data) {
modal.dataset.data = JSON.stringify(data);
}
// Handle column-modal specific setup
if (modalId === 'column-modal') {
const titleEl = $('#column-modal-title');
const nameInput = $('#column-name');
const colorInput = $('#column-color');
const deleteBtn = $('#btn-delete-column');
if (mode === 'edit' && data) {
if (titleEl) titleEl.textContent = 'Spalte bearbeiten';
if (nameInput) nameInput.value = data.name || '';
if (colorInput) colorInput.value = data.color || '#00D4FF';
if (deleteBtn) deleteBtn.style.display = '';
} else {
if (titleEl) titleEl.textContent = 'Neue Spalte';
if (nameInput) nameInput.value = '';
if (colorInput) colorInput.value = '#00D4FF';
if (deleteBtn) deleteBtn.style.display = 'none';
}
}
}
if (overlay) {
overlay.classList.remove('hidden');
overlay.classList.add('visible');
}
store.openModal(modalId);
}
handleModalClose(detail) {
const { modalId } = detail;
const modal = $(`#${modalId}`);
const overlay = $('.modal-overlay');
if (modal) {
modal.classList.remove('visible');
setTimeout(() => modal.classList.add('hidden'), 200);
}
// Only hide overlay if no other modals are open
const openModals = store.get('openModals').filter(id => id !== modalId);
if (openModals.length === 0 && overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.classList.add('hidden'), 200);
}
store.closeModal(modalId);
}
hideAllModals() {
$$('.modal').forEach(modal => {
modal.classList.remove('visible');
setTimeout(() => modal.classList.add('hidden'), 200);
});
const overlay = $('.modal-overlay');
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.classList.add('hidden'), 200);
}
}
// =====================
// TOAST NOTIFICATIONS
// =====================
showToast({ message, type = 'info', duration = 4000 }) {
const container = $('#toast-container') || this.createToastContainer();
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icon = this.getToastIcon(type);
toast.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-message">${message}</span>
<button class="toast-close">&times;</button>
`;
// Close button handler
toast.querySelector('.toast-close')?.addEventListener('click', () => {
this.removeToast(toast);
});
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.add('visible');
});
// Auto remove
if (duration > 0) {
setTimeout(() => this.removeToast(toast), duration);
}
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
document.body.appendChild(container);
return container;
}
removeToast(toast) {
toast.classList.remove('visible');
setTimeout(() => toast.remove(), 300);
}
getToastIcon(type) {
const icons = {
success: '✓',
error: '✕',
warning: '⚠',
info: ''
};
return icons[type] || icons.info;
}
showError(message) {
this.showToast({ message, type: 'error' });
}
showSuccess(message) {
this.showToast({ message, type: 'success' });
}
// =====================
// CONFIRM DIALOG
// =====================
showConfirmDialog({ message, confirmText = 'OK', confirmClass = '', onConfirm }) {
const modal = $('#confirm-modal');
const messageEl = $('#confirm-message');
const confirmBtn = $('#confirm-ok');
const cancelBtn = $('#confirm-cancel');
if (messageEl) messageEl.textContent = message;
if (confirmBtn) {
confirmBtn.textContent = confirmText;
confirmBtn.className = `btn ${confirmClass || 'btn-primary'}`;
}
// Show modal
this.handleModalOpen({ modalId: 'confirm-modal' });
// One-time event handlers
const handleConfirm = () => {
this.handleModalClose({ modalId: 'confirm-modal' });
if (onConfirm) onConfirm();
cleanup();
};
const handleCancel = () => {
this.handleModalClose({ modalId: 'confirm-modal' });
cleanup();
};
const cleanup = () => {
confirmBtn?.removeEventListener('click', handleConfirm);
cancelBtn?.removeEventListener('click', handleCancel);
};
confirmBtn?.addEventListener('click', handleConfirm);
cancelBtn?.addEventListener('click', handleCancel);
}
// =====================
// LIGHTBOX
// =====================
openLightbox({ imageUrl, filename }) {
const lightbox = $('#lightbox');
const image = $('#lightbox-image');
if (image) {
image.src = imageUrl;
image.alt = filename || '';
}
if (lightbox) {
lightbox.classList.remove('hidden');
}
// Close handlers
const closeHandler = () => {
lightbox?.classList.add('hidden');
document.removeEventListener('keydown', escHandler);
};
const escHandler = (e) => {
if (e.key === 'Escape') closeHandler();
};
$('#lightbox-close')?.addEventListener('click', closeHandler, { once: true });
lightbox?.addEventListener('click', (e) => {
if (e.target === lightbox) closeHandler();
}, { once: true });
document.addEventListener('keydown', escHandler);
}
// =====================
// FORM HANDLERS
// =====================
async handleColumnFormSubmit(e) {
e.preventDefault();
const modal = $('#column-modal');
const mode = modal?.dataset.mode;
const nameInput = $('#column-name');
const colorInput = $('#column-color');
const filterCategorySelect = $('#column-filter-category');
const customFilterInput = $('#column-custom-filter');
const name = nameInput?.value.trim();
const color = colorInput?.value || '#00D4FF';
// Filter-Kategorie ermitteln
let filterCategory = filterCategorySelect?.value || 'in_progress';
if (filterCategory === 'custom') {
filterCategory = customFilterInput?.value.trim() || 'in_progress';
}
if (!name) {
this.showError('Bitte einen Namen eingeben');
return;
}
try {
if (mode === 'create') {
await boardManager.createColumn(name, color, filterCategory);
this.showSuccess('Statuskarte erstellt');
} else {
const data = JSON.parse(modal?.dataset.data || '{}');
await boardManager.updateColumn(data.id, { name, color, filterCategory });
this.showSuccess('Statuskarte aktualisiert');
}
this.handleModalClose({ modalId: 'column-modal' });
nameInput.value = '';
if (customFilterInput) customFilterInput.value = '';
} catch (error) {
this.showError('Fehler beim Speichern');
}
}
async handleProjectFormSubmit(e) {
e.preventDefault();
const modal = $('#project-modal');
const mode = modal?.dataset.mode;
const nameInput = $('#project-name');
const descInput = $('#project-description');
const name = nameInput?.value.trim();
const description = descInput?.value.trim();
if (!name) {
this.showError('Bitte einen Namen eingeben');
return;
}
try {
if (mode === 'create') {
const project = await api.createProject({ name, description });
store.addProject(project);
await this.loadProject(project.id);
this.populateProjectSelector();
this.showSuccess('Projekt erstellt');
} else {
const data = JSON.parse(modal?.dataset.data || '{}');
await api.updateProject(data.id, { name, description });
store.updateProject(data.id, { name, description });
this.populateProjectSelector();
this.showSuccess('Projekt aktualisiert');
}
this.handleModalClose({ modalId: 'project-modal' });
nameInput.value = '';
descInput.value = '';
} catch (error) {
this.showError('Fehler beim Speichern');
}
}
async handleLabelFormSubmit(e) {
e.preventDefault();
const modal = $('#label-modal');
const mode = modal?.dataset.mode;
const nameInput = $('#label-name');
const colorInput = $('#label-color');
const name = nameInput?.value.trim();
const color = colorInput?.value;
if (!name) {
this.showError('Bitte einen Namen eingeben');
return;
}
const projectId = store.get('currentProjectId');
try {
if (mode === 'create') {
const label = await api.createLabel(projectId, { name, color });
store.addLabel(label);
this.showSuccess('Label erstellt');
} else {
const data = JSON.parse(modal?.dataset.data || '{}');
await api.updateLabel(projectId, data.id, { name, color });
store.updateLabel(data.id, { name, color });
this.showSuccess('Label aktualisiert');
}
this.handleModalClose({ modalId: 'label-modal' });
this.populateLabelFilter();
nameInput.value = '';
} catch (error) {
this.showError('Fehler beim Speichern');
}
}
openProjectModal(mode, data = {}) {
this.handleModalOpen({
modalId: 'project-modal',
mode,
data
});
const deleteBtn = $('#btn-delete-project');
const modalTitle = $('#project-modal-title');
if (mode === 'edit' && data) {
$('#project-name').value = data.name || '';
$('#project-description').value = data.description || '';
$('#project-id').value = data.id || '';
if (deleteBtn) deleteBtn.classList.remove('hidden');
if (modalTitle) modalTitle.textContent = 'Projekt bearbeiten';
} else {
$('#project-name').value = '';
$('#project-description').value = '';
$('#project-id').value = '';
if (deleteBtn) deleteBtn.classList.add('hidden');
if (modalTitle) modalTitle.textContent = 'Neues Projekt';
}
}
async handleDeleteProject() {
const projectId = parseInt($('#project-id')?.value);
if (!projectId) return;
const project = store.get('projects').find(p => p.id === projectId);
if (!project) return;
this.showConfirmDialog({
message: `Möchten Sie das Projekt "${project.name}" und alle zugehörigen Aufgaben wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`,
confirmText: 'Löschen',
confirmClass: 'btn-danger',
onConfirm: async () => {
try {
const result = await api.deleteProject(projectId, true);
store.removeProject(projectId);
// Falls das aktuelle Projekt gelöscht wurde, zu einem anderen wechseln
if (store.get('currentProjectId') === projectId) {
const projects = store.get('projects');
if (projects.length > 0) {
await this.loadProject(projects[0].id);
} else {
store.setTasks([]);
store.setColumns([]);
store.setLabels([]);
}
}
this.populateProjectSelector();
this.handleModalClose({ modalId: 'project-modal' });
this.showSuccess(`Projekt "${project.name}" wurde gelöscht`);
} catch (error) {
this.showError('Fehler beim Löschen des Projekts: ' + (error.message || 'Unbekannter Fehler'));
}
}
});
}
// =====================
// EXPORT/IMPORT
// =====================
async exportProject() {
const projectId = store.get('currentProjectId');
if (!projectId) return;
try {
const data = await api.exportProject(projectId, 'json');
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `project_${projectId}_export.json`;
a.click();
URL.revokeObjectURL(url);
this.showSuccess('Export erfolgreich');
} catch (error) {
this.showError('Export fehlgeschlagen');
}
}
async importProject() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
await api.importProject(data);
await this.loadInitialData();
this.showSuccess('Import erfolgreich');
} catch (error) {
this.showError('Import fehlgeschlagen: ' + error.message);
}
};
input.click();
}
// =====================
// ONLINE/OFFLINE
// =====================
handleOnline() {
store.setOnline(true);
this.showToast({ message: 'Verbindung wiederhergestellt', type: 'success' });
// Sync pending operations
offlineManager.syncPendingOperations();
}
handleOffline() {
store.setOnline(false);
this.showToast({ message: 'Offline-Modus aktiv', type: 'warning' });
}
// =====================
// REFRESH
// =====================
async refresh() {
const projectId = store.get('currentProjectId');
if (projectId) {
await this.loadProject(projectId);
}
}
// =====================
// SERVICE WORKER
// =====================
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('[App] Service Worker registered:', registration.scope);
} catch (error) {
console.error('[App] Service Worker registration failed:', error);
}
}
}
}
// Create app instance and initialize
const app = new App();
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => app.init());
} else {
app.init();
}
export default app;