/** * TaskFlow v1.72 - App * Copyright (c) 2026 Florian Hesse * Fischer Str. 11, 16515 Oranienburg * https://comnic-it.de * Alle Rechte vorbehalten. */ // Data Structure let users = []; let projects = []; let currentUser = null; let currentProjectId = null; let currentTodoView = 'active'; let currentProjectView = 'list'; const projectColors = [ '#667eea','#3b82f6','#06b6d4','#0d9488','#10b981', '#84cc16','#f59e0b','#f97316','#ef4444','#ec4899', '#8b5cf6','#6366f1' ]; // i18n let translations = {}; let currentLang = 'de'; // Animated counter function animateValue(el, target, suffix = '') { const start = parseInt(el.textContent) || 0; if (start === target) { el.textContent = target + suffix; return; } const duration = 500; const startTime = performance.now(); function step(now) { const progress = Math.min((now - startTime) / duration, 1); const ease = 1 - Math.pow(1 - progress, 3); // easeOutCubic el.textContent = Math.round(start + (target - start) * ease) + suffix; if (progress < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } function t(key) { return translations[key] || key; } async function loadLanguage(lang) { try { const response = await fetch(`lang/${lang}.json`); translations = await response.json(); currentLang = lang; localStorage.setItem('taskflow_lang', lang); document.documentElement.lang = lang; translatePage(); updateLangButtons(); // Re-render dynamic content renderDashboard(); renderProjects(); if (currentProjectId) { renderProjectStats(); renderProjectTodos(); } } catch (error) { console.error('Language load error:', error); } } function translatePage() { document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); if (translations[key]) el.textContent = translations[key]; }); document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.getAttribute('data-i18n-placeholder'); if (translations[key]) el.placeholder = translations[key]; }); document.querySelectorAll('[data-i18n-title]').forEach(el => { const key = el.getAttribute('data-i18n-title'); if (translations[key]) el.title = translations[key]; }); } function updateLangButtons() { document.getElementById('langBtnDe').classList.toggle('active', currentLang === 'de'); document.getElementById('langBtnEn').classList.toggle('active', currentLang === 'en'); const settingsDe = document.getElementById('settingsLangDe'); const settingsEn = document.getElementById('settingsLangEn'); if (settingsDe) settingsDe.classList.toggle('active', currentLang === 'de'); if (settingsEn) settingsEn.classList.toggle('active', currentLang === 'en'); } function changeLanguage(lang) { loadLanguage(lang); if (currentUser) { apiCall('savePreferences', { preferences: { lang } }); } } // API Helper async function apiCall(action, data = {}) { try { const response = await fetch(`api.php?action=${action}&lang=${currentLang}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) }); const result = await response.json(); return result; } catch (error) { console.error('API Error:', error); return {success: false, message: t('data.connection_error')}; } } // Version & Updates let appVersion = ''; async function loadVersion() { const result = await apiCall('getVersion'); if (result.success && result.data) { appVersion = result.data.version || ''; } document.querySelectorAll('.copyright').forEach(el => { const link = el.querySelector('a'); const linkHtml = link ? ' \u00b7 ' + link.outerHTML : ''; el.innerHTML = 'TaskFlow v' + appVersion + ' \u00a9 2026 Florian Hesse' + linkHtml; }); const versionEl = document.getElementById('currentVersion'); if (versionEl) versionEl.textContent = 'v' + appVersion; } async function checkForUpdate() { const btn = document.getElementById('checkUpdateBtn'); const status = document.getElementById('updateStatus'); const installBtn = document.getElementById('installUpdateBtn'); btn.disabled = true; btn.textContent = t('settings.update_checking'); status.style.display = 'none'; installBtn.style.display = 'none'; const result = await apiCall('checkUpdate'); btn.disabled = false; btn.textContent = t('settings.update_check'); if (result.success) { status.style.display = 'block'; if (result.data.update_available) { status.innerHTML = '
' + t('settings.update_available') + ': v' + result.data.remote + '' + '
'; installBtn.style.display = 'inline-flex'; } else { status.innerHTML = '
' + t('settings.update_up_to_date') + '
'; } } else { status.style.display = 'block'; status.innerHTML = '
' + (result.message || t('settings.update_error')) + '
'; } } async function installUpdate() { const installBtn = document.getElementById('installUpdateBtn'); const status = document.getElementById('updateStatus'); if (!await showConfirm(t('settings.update_confirm'), { icon: '๐Ÿ”„', title: t('settings.update_title'), danger: false })) return; installBtn.disabled = true; installBtn.textContent = t('settings.update_installing'); const result = await apiCall('doUpdate'); if (result.success) { status.innerHTML = '
' + result.data.message + ' (v' + result.data.version + ')' + '
'; installBtn.style.display = 'none'; loadVersion(); setTimeout(() => location.reload(), 2000); } else { status.innerHTML = '
' + (result.message || t('settings.update_error')) + '
'; installBtn.disabled = false; installBtn.textContent = t('settings.update_install'); } } // Copyright Protection (function() { const _cf = () => 'TaskFlow' + (appVersion ? ' v' + appVersion : '') + ' \u00a9 2026 Florian Hesse \u00b7 comnic-it.de'; function _pc() { document.querySelectorAll('.content-footer .copyright').forEach(el => { if (!el.innerHTML.includes('Florian Hesse')) el.innerHTML = _cf(); }); document.querySelectorAll('.login-box .copyright').forEach(el => { if (!el.innerHTML.includes('Florian Hesse')) el.innerHTML = _cf(); }); document.querySelectorAll('.content-footer').forEach(el => { el.style.removeProperty('display'); el.style.removeProperty('visibility'); el.style.removeProperty('opacity'); el.style.removeProperty('height'); el.style.removeProperty('overflow'); }); if (document.getElementById('appContainer') && !document.querySelector('.content-footer')) { const f = document.createElement('footer'); f.className = 'content-footer'; f.innerHTML = ''; document.querySelector('.main-content').appendChild(f); } } const _ob = new MutationObserver(_pc); document.addEventListener('DOMContentLoaded', function() { _pc(); _ob.observe(document.body, {childList: true, subtree: true, attributes: true, characterData: true}); }); setInterval(_pc, 3000); })(); // Initialize async function init() { let savedLang = localStorage.getItem('taskflow_lang'); if (!savedLang) { const browserLang = (navigator.language || navigator.userLanguage || 'de').substring(0, 2); savedLang = browserLang === 'en' ? 'en' : 'de'; } await loadLanguage(savedLang); loadTheme(); loadDarkMode(); applyLogo(); loadVersion(); // Check for password reset token in URL const urlParams = new URLSearchParams(window.location.search); const resetToken = urlParams.get('resetToken'); if (resetToken) { showPasswordResetForm(resetToken); // Clean URL without reload window.history.replaceState({}, document.title, window.location.pathname); return; } // Check if user is logged in const sessionResult = await apiCall('getSession'); if (sessionResult.success) { currentUser = sessionResult.data; await loadProjectsFromServer(); showApp(); } } // User Management function showLogin() { document.getElementById('loginScreen').style.display = 'flex'; document.getElementById('loginUsername').focus(); } // Enter-Key auf Login-Feldern document.addEventListener('DOMContentLoaded', () => { ['loginUsername', 'loginPassword'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') login(); }); }); const resetId = document.getElementById('resetIdentifier'); if (resetId) resetId.addEventListener('keydown', e => { if (e.key === 'Enter') requestPasswordReset(); }); ['resetNewPassword', 'resetConfirmPassword'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') submitPasswordReset(); }); }); }); async function login() { const username = document.getElementById('loginUsername').value.trim(); const password = document.getElementById('loginPassword').value; if (!username || !password) { showToast('Login', t('login.alert_fields'), 'warning'); return; } const result = await apiCall('login', {username, password}); if (result.success) { currentUser = result.data; await loadProjectsFromServer(); showApp(); } else { showToast('Login', result.message || t('login.alert_failed'), 'error'); } } async function logout() { if (await showConfirm(t('nav.logout_confirm'), { icon: '๐Ÿšช', danger: false })) { await apiCall('logout'); currentUser = null; projects = []; document.getElementById('appContainer').classList.remove('active'); document.getElementById('loginUsername').value = ''; document.getElementById('loginPassword').value = ''; showLogin(); } } // Password Reset Flow function showForgotPassword() { document.getElementById('loginBox').style.display = 'none'; document.getElementById('forgotPasswordBox').style.display = 'block'; document.getElementById('resetPasswordBox').style.display = 'none'; document.getElementById('resetIdentifier').value = ''; document.getElementById('resetIdentifier').focus(); } function backToLogin() { document.getElementById('loginBox').style.display = 'block'; document.getElementById('forgotPasswordBox').style.display = 'none'; document.getElementById('resetPasswordBox').style.display = 'none'; } async function requestPasswordReset() { const identifier = document.getElementById('resetIdentifier').value.trim(); if (!identifier) { showToast(t('reset.title'), t('reset.password_required'), 'warning'); return; } await apiCall('requestPasswordReset', { identifier }); showToast(t('reset.title'), t('reset.email_sent'), 'success'); backToLogin(); } let pendingResetToken = null; function showPasswordResetForm(token) { pendingResetToken = token; document.getElementById('loginScreen').style.display = 'flex'; document.getElementById('loginBox').style.display = 'none'; document.getElementById('forgotPasswordBox').style.display = 'none'; document.getElementById('resetPasswordBox').style.display = 'block'; document.getElementById('resetNewPassword').value = ''; document.getElementById('resetConfirmPassword').value = ''; document.getElementById('resetNewPassword').focus(); } async function submitPasswordReset() { const pw = document.getElementById('resetNewPassword').value; const pw2 = document.getElementById('resetConfirmPassword').value; if (!pw) { showToast(t('reset.title'), t('reset.password_required'), 'warning'); return; } if (pw !== pw2) { showToast(t('reset.title'), t('reset.password_mismatch'), 'warning'); return; } const result = await apiCall('resetPassword', { token: pendingResetToken, password: pw }); if (result.success) { showToast(t('reset.title'), t('reset.success'), 'success'); pendingResetToken = null; backToLogin(); } else { showToast(t('reset.title'), result.message || t('reset.error'), 'error'); } } async function showApp() { document.getElementById('loginScreen').style.display = 'none'; document.getElementById('appContainer').classList.add('active'); document.getElementById('userName').textContent = currentUser.name; document.getElementById('userAvatar').textContent = currentUser.name.charAt(0).toUpperCase(); const isAdmin = currentUser.role === 'admin'; const navUsersItem = document.getElementById('navUsersItem'); if (navUsersItem) navUsersItem.style.display = isAdmin ? '' : 'none'; const userRoleEl = document.getElementById('userRole'); if (userRoleEl) userRoleEl.textContent = isAdmin ? t('users.role_admin') : t('users.role_user'); // Load per-user preferences from server await loadUserPreferences(); renderDashboard(); loadNotifications(); } async function loadUserPreferences() { const result = await apiCall('getPreferences'); if (result.success && result.data) { const prefs = result.data; if (prefs.theme) { localStorage.setItem('taskflow_theme', prefs.theme); changeTheme(prefs.theme, false); } if (prefs.darkMode !== undefined) { const isDark = !!prefs.darkMode; localStorage.setItem('taskflow_dark', isDark ? 'true' : 'false'); document.body.setAttribute('data-dark', isDark); updateDarkModeUI(isDark); } if (prefs.lang && prefs.lang !== currentLang) { await loadLanguage(prefs.lang); } } } // Project Management async function loadProjectsFromServer() { const result = await apiCall('getProjects'); if (result.success) { projects = result.data || []; } } function openFeedback() { const repoUrl = 'https://github.com/floppy007/taskflow/issues/new/choose'; window.open(repoUrl, '_blank'); } function openNewProjectModal() { document.getElementById('newProjectColor').value = ''; renderColorPicker('newProjectColorPicker', 'newProjectColor', ''); document.getElementById('newProjectModal').classList.add('active'); document.getElementById('newProjectName').focus(); } function renderColorPicker(containerId, inputId, activeColor) { const container = document.getElementById(containerId); container.innerHTML = projectColors.map(c => `
` ).join(''); } function selectProjectColor(inputId, containerId, color) { document.getElementById(inputId).value = color; document.querySelectorAll(`#${containerId} .color-dot`).forEach(d => d.classList.remove('active')); event.target.classList.add('active'); } function closeModal(id) { document.getElementById(id).classList.remove('active'); } async function createProject() { const name = document.getElementById('newProjectName').value.trim(); const desc = document.getElementById('newProjectDesc').value.trim(); if (!name) { showToast(t('topbar.new_project'), t('projects.name_required'), 'warning'); return; } const color = document.getElementById('newProjectColor').value; const result = await apiCall('createProject', {name, desc, color}); if (result.success) { await loadProjectsFromServer(); document.getElementById('newProjectName').value = ''; document.getElementById('newProjectDesc').value = ''; closeModal('newProjectModal'); renderDashboard(); renderProjects(); } else { showToast(t('topbar.new_project'), result.message || t('projects.create_error'), 'error'); } } // Views function showDashboard() { setActiveNav(0); hideAllViews(); showViewAnimated('dashboardView'); renderDashboard(); } function showProjects() { setActiveNav(1); hideAllViews(); showViewAnimated('projectsView'); renderProjects(); loadDeletedProjects(); } async function showUsers() { if (currentUser.role !== 'admin') { showDashboard(); return; } setActiveNav(2); hideAllViews(); showViewAnimated('usersView'); const result = await apiCall('getUsers'); if (result.success) { users = result.data; renderUsers(); } // Show LDAP import button if LDAP is enabled const importBtn = document.getElementById('importLdapUsersBtn'); if (importBtn) { const ldapResult = await apiCall('getLdapConfig'); importBtn.style.display = (ldapResult.success && ldapResult.data && ldapResult.data.enabled) ? '' : 'none'; } } function showSettings() { setActiveNav(3); hideAllViews(); showViewAnimated('settingsView'); // Hide password card for LDAP users const pwCard = document.getElementById('passwordChangeCard'); if (pwCard) { pwCard.style.display = (currentUser.source === 'ldap') ? 'none' : ''; } // Load LDAP and SMTP config for admins loadLdapConfig(); loadSmtpConfig(); } function hideAllViews() { ['dashboardView','projectsView','usersView','settingsView','projectDetailView'].forEach(id => { const el = document.getElementById(id); el.style.display = 'none'; el.style.opacity = '0'; el.style.transform = 'translateY(12px)'; }); } function showViewAnimated(id) { const el = document.getElementById(id); el.style.display = 'block'; el.style.opacity = '0'; el.style.transform = 'translateY(12px)'; requestAnimationFrame(() => { el.style.transition = 'opacity .3s ease, transform .3s ease'; el.style.opacity = '1'; el.style.transform = 'translateY(0)'; }); } function setActiveNav(index) { document.querySelectorAll('.nav-item').forEach((item, i) => { item.classList.toggle('active', i === index); }); } // Render Functions function renderDashboard() { const totalProjects = projects.length; const allTodos = projects.flatMap(p => p.todos || []); const activeTodos = allTodos.filter(t => !t.archived); const openTodos = activeTodos.filter(t => !t.done).length; const doneTodos = activeTodos.filter(t => t.done).length; const progress = activeTodos.length ? Math.round((doneTodos / activeTodos.length) * 100) : 0; animateValue(document.getElementById('statProjects'), totalProjects); animateValue(document.getElementById('statOpen'), openTodos); animateValue(document.getElementById('statDone'), doneTodos); animateValue(document.getElementById('statProgress'), progress, '%'); document.getElementById('projectCount').textContent = totalProjects; renderActivityFeed(); const container = document.getElementById('dashboardProjects'); if (projects.length === 0) { container.innerHTML = `
๐Ÿ“
${escapeHtml(t('projects.empty_title'))}
${escapeHtml(t('projects.empty_text'))}
`; return; } container.innerHTML = projects.map(p => { const todos = p.todos || []; const activeTodos = todos.filter(t => !t.archived); const total = activeTodos.length; const done = activeTodos.filter(t => t.done).length; const open = total - done; const pct = total ? Math.round((done / total) * 100) : 0; const canDelete = canDeleteProject(p); const canManage = canDeleteProject(p); return `
๐Ÿ“
${canManage || canDelete ? `
${canManage ? `` : ''} ${canDelete ? `` : ''}
` : ''}
${escapeHtml(p.name)}
${escapeHtml(p.desc || t('projects.no_desc'))}
${escapeHtml(t('projects.progress'))} ${pct}%
${open} ${escapeHtml(t('projects.open'))}
${done} ${escapeHtml(t('projects.done'))}
${total} ${escapeHtml(t('projects.total'))}
`; }).join(''); } function canDeleteProject(p) { if (currentUser.role === 'admin') return true; if (p.members) { return p.members.some(m => m.userId === currentUser.id && m.role === 'owner'); } return p.createdBy === currentUser.id; } function renderProjects() { const container = document.getElementById('projectsList'); if (projects.length === 0) { container.innerHTML = `
๐Ÿ“
${escapeHtml(t('projects.empty_title'))}
${escapeHtml(t('projects.empty_text'))}
`; return; } container.innerHTML = projects.map(p => { const todos = p.todos || []; const activeTodos = todos.filter(t => !t.archived); const total = activeTodos.length; const done = activeTodos.filter(t => t.done).length; const open = total - done; const pct = total ? Math.round((done / total) * 100) : 0; const canDelete = canDeleteProject(p); const canManage = canDeleteProject(p); return `
๐Ÿ“
${canManage || canDelete ? `
${canManage ? `` : ''} ${canDelete ? `` : ''}
` : ''}
${escapeHtml(p.name)}
${escapeHtml(p.desc || t('projects.no_desc'))}
${escapeHtml(t('projects.progress'))} ${pct}%
${open} ${escapeHtml(t('projects.open'))}
${done} ${escapeHtml(t('projects.done'))}
${total} ${escapeHtml(t('projects.total'))}
`; }).join(''); } function renderUsers() { const container = document.getElementById('usersList'); container.innerHTML = users.map(u => { const role = u.role || 'admin'; const isAdmin = role === 'admin'; const badgeClass = isAdmin ? 'badge-admin' : 'badge-user'; const badgeLabel = isAdmin ? t('users.role_admin') : t('users.role_user'); const badgeIcon = isAdmin ? '๐Ÿ›ก๏ธ' : '๐Ÿ‘ค'; const newRole = isAdmin ? 'user' : 'admin'; const source = u.source || 'local'; const sourceBadge = source === 'ldap' ? 'AD/LDAP' : '' + t('users.source_local') + ''; const emailDisplay = u.email ? '
โœ‰ ' + escapeHtml(u.email) + '
' : ''; return `
${u.name.charAt(0).toUpperCase()}
${escapeHtml(u.name)}
@${escapeHtml(u.username)}
${emailDisplay}
${sourceBadge} ${badgeIcon} ${escapeHtml(badgeLabel)}
${escapeHtml(t('users.created'))} ${new Date(u.createdAt).toLocaleDateString(currentLang === 'de' ? 'de-DE' : 'en-US')}
${u.id !== currentUser.id ? `` : ''} ${u.id !== currentUser.id ? `` : ''}
`; }).join(''); } async function deleteProject(id) { if (await showConfirm(t('projects.delete_confirm'), { icon: '๐Ÿ—‘๏ธ', title: t('project_detail.delete') })) { const result = await apiCall('deleteProject', {id}); if (result.success) { await loadProjectsFromServer(); renderDashboard(); renderProjects(); } else { showToast(t('project_detail.delete'), result.message || t('projects.delete_error'), 'error'); } } } // Project Detail View Functions function openProjectDetail(projectId) { currentProjectId = projectId; const project = projects.find(p => p.id === projectId); if (!project) return; hideAllViews(); showViewAnimated('projectDetailView'); document.getElementById('projectDetailTitle').textContent = project.name; document.getElementById('projectDetailDesc').textContent = project.desc || t('projects.no_desc'); currentTodoView = 'active'; currentProjectView = 'kanban'; switchTodoView('active'); renderProjectStats(); switchProjectView('kanban'); renderMembers(project); } function backToProjects() { currentProjectId = null; showProjects(); } function editProject() { const project = projects.find(p => p.id === currentProjectId); if (!project) return; document.getElementById('editProjectName').value = project.name; document.getElementById('editProjectDesc').value = project.desc || ''; document.getElementById('editProjectColor').value = project.color || ''; renderColorPicker('editProjectColorPicker', 'editProjectColor', project.color || ''); document.getElementById('editProjectModal').classList.add('active'); document.getElementById('editProjectName').focus(); } async function saveEditProject() { const name = document.getElementById('editProjectName').value.trim(); if (!name) { showToast(t('modal.edit_project'), t('projects.name_required'), 'warning'); return; } const result = await apiCall('updateProject', { id: currentProjectId, name, desc: document.getElementById('editProjectDesc').value.trim(), color: document.getElementById('editProjectColor').value }); if (result.success) { closeModal('editProjectModal'); await loadProjectsFromServer(); const updated = projects.find(p => p.id === currentProjectId); document.getElementById('projectDetailTitle').textContent = updated.name; document.getElementById('projectDetailDesc').textContent = updated.desc || t('projects.no_desc'); renderDashboard(); renderProjects(); } else { showToast(t('modal.edit_project'), result.message || t('projects.edit_error'), 'error'); } } async function deleteCurrentProject() { if (!await showConfirm(t('projects.delete_confirm'), { icon: '๐Ÿ—‘๏ธ', title: t('project_detail.delete') })) return; const result = await apiCall('deleteProject', {id: currentProjectId}); if (result.success) { await loadProjectsFromServer(); backToProjects(); renderDashboard(); } else { showToast(t('project_detail.delete'), result.message || t('projects.delete_error'), 'error'); } } function renderProjectStats() { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const activeTodos = (project.todos || []).filter(t => !t.archived); const total = activeTodos.length; const done = activeTodos.filter(t => t.done).length; const open = total - done; const progress = total ? Math.round((done / total) * 100) : 0; animateValue(document.getElementById('projectStatTotal'), total); animateValue(document.getElementById('projectStatOpen'), open); animateValue(document.getElementById('projectStatDone'), done); animateValue(document.getElementById('projectStatProgress'), progress, '%'); } function toggleNewTodoForm() { const form = document.getElementById('newTodoFormCard'); const btn = document.getElementById('newTodoToggleBtn'); const isVisible = form.style.display !== 'none'; if (isVisible) { form.style.opacity = '0'; form.style.transform = 'translateY(-10px)'; setTimeout(() => { form.style.display = 'none'; btn.style.display = 'inline-flex'; }, 200); } else { form.style.display = 'block'; form.style.opacity = '0'; form.style.transform = 'translateY(-10px)'; requestAnimationFrame(() => { form.style.transition = 'opacity .2s, transform .2s'; form.style.opacity = '1'; form.style.transform = 'translateY(0)'; }); btn.style.display = 'none'; document.getElementById('newTodoText').focus(); } } async function addTodoToProject() { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const text = document.getElementById('newTodoText').value.trim(); if (!text) { showToast(t('todos.new_title'), t('todos.alert_required'), 'warning'); return; } const category = document.getElementById('newTodoCategory').value; const priority = document.getElementById('newTodoPriority').value; const note = document.getElementById('newTodoNote').value.trim(); const dueDate = document.getElementById('newTodoDueDate').value || null; const result = await apiCall('addTodo', { projectId: currentProjectId, text, category, priority, note, dueDate }); if (result.success) { await loadProjectsFromServer(); document.getElementById('newTodoText').value = ''; document.getElementById('newTodoNote').value = ''; document.getElementById('newTodoDueDate').value = ''; toggleNewTodoForm(); renderProjectStats(); renderProjectTodos(); renderDashboard(); } else { showToast(t('todos.new_title'), result.message || t('todos.alert_add_error'), 'error'); } } function switchTodoView(view) { currentTodoView = view; const activeBtn = document.getElementById('viewActiveBtn'); const archiveBtn = document.getElementById('viewArchiveBtn'); if (view === 'active') { activeBtn.style.background = 'var(--card)'; activeBtn.style.boxShadow = 'var(--shadow-sm)'; activeBtn.classList.remove('btn-ghost'); archiveBtn.style.background = 'transparent'; archiveBtn.style.boxShadow = 'none'; archiveBtn.classList.add('btn-ghost'); } else { archiveBtn.style.background = 'var(--card)'; archiveBtn.style.boxShadow = 'var(--shadow-sm)'; archiveBtn.classList.remove('btn-ghost'); activeBtn.style.background = 'transparent'; activeBtn.style.boxShadow = 'none'; activeBtn.classList.add('btn-ghost'); } renderProjectTodos(); } function renderProjectTodos() { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const container = document.getElementById('todoListContainer'); const filterCat = document.getElementById('filterCategory').value; const filterStat = document.getElementById('filterStatus').value; let todos = (project.todos || []).filter(td => { if (currentTodoView === 'active' && td.archived) return false; if (currentTodoView === 'archive' && !td.archived) return false; if (filterCat !== 'all' && td.category !== filterCat) return false; if (filterStat === 'open' && td.done) return false; if (filterStat === 'done' && !td.done) return false; return true; }); if (todos.length === 0) { container.innerHTML = `
${currentTodoView === 'archive' ? '๐Ÿ“ฆ' : 'โœ“'}

${escapeHtml(currentTodoView === 'archive' ? t('todos.empty_archive') : t('todos.empty_active'))}

`; return; } const grouped = {}; todos.forEach(todo => { const cat = todo.category || 'Other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(todo); }); const categoryIcons = { Development: '๐Ÿ’ป', Design: '๐ŸŽจ', Content: '๐Ÿ“', Testing: '๐Ÿงช', Meeting: '๐Ÿ‘ฅ', Other: '๐Ÿ“Œ' }; const priorityLabels = { low: t('todos.priority_low'), medium: t('todos.priority_medium'), high: t('todos.priority_high') }; let html = ''; Object.keys(grouped).forEach(category => { html += `
${categoryIcons[category] || '๐Ÿ“Œ'} ${category}
`; grouped[category].forEach(todo => { html += `
โ‹ฎโ‹ฎ
${todo.done ? 'โœ“' : ''}
${escapeHtml(todo.text)}
${categoryIcons[todo.category] || '๐Ÿ“Œ'} ${todo.category} ${priorityLabels[todo.priority]} ${todo.dueDate ? (() => { const di = getDueDateInfo(todo.dueDate); return `๐Ÿ“… ${di.label}`; })() : ''} ${(todo.attachments && todo.attachments.length > 0) ? `๐Ÿ“Ž ${todo.attachments.length}` : ''}
${todo.createdBy ? `${escapeHtml(t('todos.created_by'))} @${escapeHtml(todo.createdBy)}` : ''} ${todo.done && todo.closedBy ? `${escapeHtml(t('todos.closed_by'))} @${escapeHtml(todo.closedBy)}${todo.closedAt ? ' (' + new Date(todo.closedAt).toLocaleDateString(currentLang === 'de' ? 'de-DE' : 'en-US') + ')' : ''}` : ''}
${todo.note ? `
${escapeHtml(todo.note)}
` : ''} ${renderInlineAttachments(todo)}
`; }); html += '
'; }); container.innerHTML = html; } async function toggleTodo(todoId) { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const todo = (project.todos || []).find(t => t.id === todoId); if (!todo) return; const result = await apiCall('updateTodo', { projectId: currentProjectId, todoId, updates: {done: !todo.done} }); if (result.success) { await loadProjectsFromServer(); renderProjectStats(); renderProjectTodos(); renderDashboard(); } } function editTodo(todoId) { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const todo = (project.todos || []).find(t => t.id === todoId); if (!todo) return; document.getElementById('editTodoId').value = todoId; document.getElementById('editTodoText').value = todo.text; document.getElementById('editTodoCategory').value = todo.category || 'Other'; document.getElementById('editTodoPriority').value = todo.priority || 'medium'; document.getElementById('editTodoDueDate').value = todo.dueDate || ''; document.getElementById('editTodoNote').value = todo.note || ''; // Render attachments in the modal renderAttachments(todo.attachments || []); initAttachmentDropZone(); document.getElementById('editTodoModal').classList.add('active'); document.getElementById('editTodoText').focus(); } async function saveEditTodo() { const todoId = parseInt(document.getElementById('editTodoId').value); const text = document.getElementById('editTodoText').value.trim(); if (!text) { showToast(t('todos.edit_title'), t('todos.alert_required'), 'warning'); return; } const result = await apiCall('updateTodo', { projectId: currentProjectId, todoId, updates: { text, category: document.getElementById('editTodoCategory').value, priority: document.getElementById('editTodoPriority').value, dueDate: document.getElementById('editTodoDueDate').value || null, note: document.getElementById('editTodoNote').value.trim() } }); if (result.success) { closeModal('editTodoModal'); await loadProjectsFromServer(); renderProjectTodos(); if (currentProjectView === 'kanban') renderKanbanBoard(); renderDashboard(); } } async function archiveTodo(todoId) { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const todo = (project.todos || []).find(t => t.id === todoId); if (!todo) return; const result = await apiCall('updateTodo', { projectId: currentProjectId, todoId, updates: {archived: !todo.archived} }); if (result.success) { await loadProjectsFromServer(); renderProjectStats(); renderProjectTodos(); renderDashboard(); } } async function deleteTodo(todoId) { if (!await showConfirm(t('todos.delete_confirm'), { icon: '๐Ÿ—‘๏ธ', title: t('todos.delete_btn') })) return; const result = await apiCall('deleteTodo', { projectId: currentProjectId, todoId }); if (result.success) { await loadProjectsFromServer(); renderProjectStats(); renderProjectTodos(); renderDashboard(); } } // Data Management async function exportData() { const result = await apiCall('exportData'); if (result.success) { const data = JSON.stringify(result.data, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `taskflow_backup_${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); showToast(t('settings.backup_title'), t('data.export_success'), 'success'); } else { showToast(t('settings.backup_title'), result.message || t('data.export_failed'), 'error'); } } async function importData(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (e) => { try { const data = JSON.parse(e.target.result); if (!data.users || !data.projects) { showToast(t('settings.backup_title'), t('data.import_invalid'), 'error'); return; } if (await showConfirm(t('data.import_confirm'), { icon: 'โš ๏ธ', title: t('settings.backup_title') })) { const result = await apiCall('importData', data); if (result.success) { await loadProjectsFromServer(); renderDashboard(); showToast(t('settings.backup_title'), t('data.import_success'), 'success'); } else { showToast(t('settings.backup_title'), result.message || t('data.import_failed'), 'error'); } } } catch (error) { showToast(t('settings.backup_title'), t('data.import_read_error') + error.message, 'error'); } }; reader.readAsText(file); } // Password Change async function changePassword() { const currentPw = document.getElementById('currentPassword').value; const newPw = document.getElementById('newPassword').value; const confirmPw = document.getElementById('confirmPassword').value; if (!currentPw || !newPw || !confirmPw) { showToast(t('settings.password_title'), t('settings.password_fields_required'), 'warning'); return; } if (newPw !== confirmPw) { showToast(t('settings.password_title'), t('settings.password_mismatch'), 'warning'); return; } const result = await apiCall('changePassword', {currentPassword: currentPw, newPassword: newPw}); if (result.success) { showToast(t('settings.password_title'), t('settings.password_success'), 'success'); document.getElementById('currentPassword').value = ''; document.getElementById('newPassword').value = ''; document.getElementById('confirmPassword').value = ''; } else { showToast(t('settings.password_title'), result.message || t('settings.password_error'), 'error'); } } // User Creation function openCreateUserForm() { document.getElementById('createUserCard').style.display = 'block'; } function closeCreateUserForm() { document.getElementById('createUserCard').style.display = 'none'; document.getElementById('newUserName').value = ''; document.getElementById('newUserUsername').value = ''; document.getElementById('newUserPassword').value = ''; document.getElementById('newUserRole').value = 'user'; } async function createUser() { const name = document.getElementById('newUserName').value; const username = document.getElementById('newUserUsername').value; const password = document.getElementById('newUserPassword').value; const role = document.getElementById('newUserRole').value; const email = document.getElementById('newUserEmail') ? document.getElementById('newUserEmail').value.trim() : ''; if (!name || !username || !password) { showToast(t('users.create_title'), t('register.alert_fields'), 'warning'); return; } const result = await apiCall('createUser', {name, username, password, role, email}); if (result.success) { closeCreateUserForm(); showToast(t('users.create_title'), result.message, 'success'); const usersResult = await apiCall('getUsers'); if (usersResult.success) { users = usersResult.data; renderUsers(); } } else { showToast(t('users.create_title'), result.message || t('users.create_error'), 'error'); } } // User Deletion async function deleteUser(id) { if (!await showConfirm(t('users.delete_confirm'), { icon: '๐Ÿ—‘๏ธ', title: t('users.delete_btn') })) return; const result = await apiCall('deleteUser', {id}); if (result.success) { showToast(t('users.title'), result.message, 'success'); const usersResult = await apiCall('getUsers'); if (usersResult.success) { users = usersResult.data; renderUsers(); } } else { showToast(t('users.title'), result.message || t('users.delete_error'), 'error'); } } // Toggle User Role async function toggleUserRole(id, newRole) { const result = await apiCall('updateUserRole', {id, role: newRole}); if (result.success) { const usersResult = await apiCall('getUsers'); if (usersResult.success) { users = usersResult.data; renderUsers(); } } else { showToast(t('users.title'), result.message || t('users.no_permission'), 'error'); } } function applyLogo() { const logoDataUrl = localStorage.getItem('taskflow_logo'); const loginLogo = document.getElementById('loginLogo'); const forgotLogo = document.getElementById('forgotLogo'); const resetLogo = document.getElementById('resetLogo'); const sidebarLogo = document.getElementById('sidebarLogo'); const logoSrc = logoDataUrl || 'logo.png'; if (loginLogo) loginLogo.innerHTML = `TaskFlow`; if (forgotLogo) forgotLogo.innerHTML = `TaskFlow`; if (resetLogo) resetLogo.innerHTML = `TaskFlow`; if (sidebarLogo) sidebarLogo.innerHTML = `TaskFlow`; } // Theme Management function changeTheme(theme, save = true) { document.body.setAttribute('data-theme', theme); localStorage.setItem('taskflow_theme', theme); document.querySelectorAll('.theme-option').forEach(opt => { opt.classList.remove('active'); }); const opt = document.querySelector(`.theme-option[data-theme="${theme}"]`); if (opt) opt.classList.add('active'); if (save && currentUser) { apiCall('savePreferences', { preferences: { theme } }); } } function loadTheme() { const savedTheme = localStorage.getItem('taskflow_theme') || 'purple'; changeTheme(savedTheme, false); } // Due Date Helper function getDueDateInfo(dueDate) { if (!dueDate) return null; const now = new Date(); now.setHours(0,0,0,0); const due = new Date(dueDate); due.setHours(0,0,0,0); const diff = Math.ceil((due - now) / (1000*60*60*24)); if (diff < 0) return { class: 'overdue', label: t('todos.due_overdue') }; if (diff === 0) return { class: 'today', label: t('todos.due_today') }; if (diff <= 3) return { class: 'upcoming', label: due.toLocaleDateString(currentLang === 'de' ? 'de-DE' : 'en-US') }; return { class: 'later', label: due.toLocaleDateString(currentLang === 'de' ? 'de-DE' : 'en-US') }; } // Dark Mode (auto-detect system, user can override via topbar) function toggleDarkMode() { const isDark = document.body.getAttribute('data-dark') === 'true'; const newVal = !isDark; document.body.setAttribute('data-dark', newVal); localStorage.setItem('taskflow_dark', newVal ? 'true' : 'false'); updateDarkModeUI(newVal); if (currentUser) { apiCall('savePreferences', { preferences: { darkMode: newVal } }); } } function loadDarkMode() { const saved = localStorage.getItem('taskflow_dark'); let isDark; if (saved !== null) { isDark = saved === 'true'; } else { isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; } document.body.setAttribute('data-dark', isDark); updateDarkModeUI(isDark); // Listen for system theme changes (only if user hasn't overridden) window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (localStorage.getItem('taskflow_dark') === null) { document.body.setAttribute('data-dark', e.matches); updateDarkModeUI(e.matches); } }); } function updateDarkModeUI(isDark) { const toggle = document.getElementById('darkModeToggle'); if (toggle) toggle.textContent = isDark ? 'โ˜€๏ธ' : '๐ŸŒ™'; } // Kanban Board function switchProjectView(view) { currentProjectView = view; const listBtn = document.getElementById('listViewBtn'); const kanbanBtn = document.getElementById('kanbanViewBtn'); const kanbanContainer = document.getElementById('kanbanContainer'); const newTodoBtn = document.getElementById('newTodoToggleBtn'); const newTodoForm = document.getElementById('newTodoFormCard'); const todoCard = document.getElementById('todoListContainer')?.closest('.card'); if (view === 'list') { listBtn.style.background = 'var(--card)'; listBtn.style.boxShadow = 'var(--shadow-sm)'; listBtn.classList.remove('btn-ghost'); kanbanBtn.style.background = 'transparent'; kanbanBtn.style.boxShadow = 'none'; kanbanBtn.classList.add('btn-ghost'); if (todoCard) todoCard.style.display = ''; if (newTodoBtn) newTodoBtn.style.display = ''; kanbanContainer.style.display = 'none'; renderProjectTodos(); } else { kanbanBtn.style.background = 'var(--card)'; kanbanBtn.style.boxShadow = 'var(--shadow-sm)'; kanbanBtn.classList.remove('btn-ghost'); listBtn.style.background = 'transparent'; listBtn.style.boxShadow = 'none'; listBtn.classList.add('btn-ghost'); if (newTodoForm) newTodoForm.style.display = 'none'; if (newTodoBtn) newTodoBtn.style.display = 'none'; if (todoCard) todoCard.style.display = 'none'; kanbanContainer.style.display = 'block'; renderKanbanBoard(); } } function renderKanbanBoard() { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const container = document.getElementById('kanbanContainer'); const todos = (project.todos || []).filter(td => !td.archived); const columns = { todo: { title: t('kanban.col_todo'), icon: '๐Ÿ“‹', items: [] }, inprogress: { title: t('kanban.col_inprogress'), icon: '๐Ÿ”„', items: [] }, done: { title: t('kanban.col_done'), icon: 'โœ…', items: [] } }; todos.forEach(td => { const status = td.status || (td.done ? 'done' : 'todo'); if (columns[status]) columns[status].items.push(td); else columns.todo.items.push(td); }); const categoryIcons = { Development:'๐Ÿ’ป', Design:'๐ŸŽจ', Content:'๐Ÿ“', Testing:'๐Ÿงช', Meeting:'๐Ÿ‘ฅ', Other:'๐Ÿ“Œ' }; const priorityLabels = { low: t('todos.priority_low'), medium: t('todos.priority_medium'), high: t('todos.priority_high') }; container.innerHTML = '
' + Object.entries(columns).map(([status, col]) => `
${col.icon} ${col.title} ${col.items.length}
${col.items.map(td => `
${escapeHtml(td.text)}
${categoryIcons[td.category]||'๐Ÿ“Œ'} ${td.category} ${priorityLabels[td.priority]} ${td.dueDate ? (() => { const di = getDueDateInfo(td.dueDate); return `๐Ÿ“… ${di.label}`; })() : ''} ${(td.attachments && td.attachments.length > 0) ? `๐Ÿ“Ž ${td.attachments.length}` : ''}
${renderInlineAttachments(td)}
`).join('')}
`).join('') + '
'; } async function dropKanbanCard(event, newStatus) { event.preventDefault(); const todoId = parseInt(event.dataTransfer.getData('text/plain')); const updates = { status: newStatus }; if (newStatus === 'done') updates.done = true; else updates.done = false; const result = await apiCall('updateTodo', { projectId: currentProjectId, todoId, updates }); if (result.success) { await loadProjectsFromServer(); renderKanbanBoard(); renderProjectStats(); renderDashboard(); } } // Global Search let searchTimeout = null; function onSearchInput() { clearTimeout(searchTimeout); const query = document.getElementById('globalSearchInput').value.trim().toLowerCase(); if (query.length < 2) { document.getElementById('searchResults').classList.remove('active'); return; } searchTimeout = setTimeout(() => performSearch(query), 250); } function performSearch(query) { const results = []; projects.forEach(p => { if (p.name.toLowerCase().includes(query)) { results.push({ type: 'project', projectId: p.id, title: p.name, context: p.desc || '' }); } (p.todos || []).forEach(td => { if (td.archived) return; const matchText = td.text.toLowerCase().includes(query); const matchNote = (td.note || '').toLowerCase().includes(query); if (matchText || matchNote) { results.push({ type: 'todo', projectId: p.id, todoId: td.id, title: td.text, context: p.name + (matchNote ? ' - ' + td.note.substring(0, 80) : '') }); } }); }); const container = document.getElementById('searchResults'); if (results.length === 0) { container.innerHTML = `
${escapeHtml(t('search.no_results'))}
`; container.classList.add('active'); return; } container.innerHTML = results.slice(0, 15).map(r => `
${r.type === 'project' ? '๐Ÿ“ ' + t('search.type_project') : 'โœ“ ' + t('search.type_task')}
${escapeHtml(r.title)}
${escapeHtml(r.context)}
`).join(''); container.classList.add('active'); } function navigateToSearchResult(projectId) { document.getElementById('searchResults').classList.remove('active'); document.getElementById('globalSearchInput').value = ''; openProjectDetail(projectId); } document.addEventListener('click', (e) => { if (!e.target.closest('.search-bar')) { const sr = document.getElementById('searchResults'); if (sr) sr.classList.remove('active'); } }); // Activity Feed async function renderActivityFeed() { const container = document.getElementById('activityFeed'); if (!container) return; const result = await apiCall('getActivity', { count: 20 }); if (!result.success || !result.data || result.data.length === 0) { container.innerHTML = `

${escapeHtml(t('activity.empty'))}

`; return; } const actionIcons = { user_login: '๐Ÿ”‘', project_created: '๐Ÿ“', project_deleted: '๐Ÿ—‘๏ธ', todo_created: 'โž•', todo_completed: 'โœ…', todo_deleted: 'โŒ' }; const actionLabels = { user_login: t('activity.user_login'), project_created: t('activity.project_created'), project_deleted: t('activity.project_deleted'), todo_created: t('activity.todo_created'), todo_completed: t('activity.todo_completed'), todo_deleted: t('activity.todo_deleted') }; container.innerHTML = result.data.map(a => `
${actionIcons[a.action] || '๐Ÿ“Œ'}
${escapeHtml(a.userName)} ${escapeHtml(actionLabels[a.action] || a.action)} ${a.projectName ? ' - ' + escapeHtml(a.projectName) + '' : ''} ${a.todoText ? ': ' + escapeHtml(a.todoText) : ''}
${timeAgo(a.timestamp)}
`).join(''); } function timeAgo(dateStr) { const now = new Date(); const date = new Date(dateStr); const seconds = Math.floor((now - date) / 1000); if (seconds < 60) return t('activity.just_now'); const minutes = Math.floor(seconds / 60); if (minutes < 60) return minutes + ' ' + t('activity.minutes_ago'); const hours = Math.floor(minutes / 60); if (hours < 24) return hours + ' ' + t('activity.hours_ago'); const days = Math.floor(hours / 24); if (days < 7) return days + ' ' + t('activity.days_ago'); return date.toLocaleDateString(currentLang === 'de' ? 'de-DE' : 'en-US'); } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // Todo Drag & Drop Reorder let draggedTodoId = null; function todoDragStart(e) { draggedTodoId = parseInt(e.currentTarget.dataset.todoId); e.currentTarget.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; } function todoDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const item = e.currentTarget.closest('.todo-item'); if (item) { document.querySelectorAll('.todo-item.drag-over').forEach(el => el.classList.remove('drag-over')); item.classList.add('drag-over'); } } function todoDragEnd(e) { e.currentTarget.classList.remove('dragging'); document.querySelectorAll('.todo-item.drag-over').forEach(el => el.classList.remove('drag-over')); draggedTodoId = null; } async function todoDrop(e) { e.preventDefault(); const targetItem = e.currentTarget.closest('.todo-item'); if (!targetItem) return; const targetId = parseInt(targetItem.dataset.todoId); if (draggedTodoId === null || draggedTodoId === targetId) return; const project = projects.find(p => p.id === currentProjectId); if (!project) return; const todos = project.todos || []; const todoIds = todos.map(t => t.id); const fromIdx = todoIds.indexOf(draggedTodoId); const toIdx = todoIds.indexOf(targetId); if (fromIdx === -1 || toIdx === -1) return; todoIds.splice(fromIdx, 1); todoIds.splice(toIdx, 0, draggedTodoId); const result = await apiCall('reorderTodos', { projectId: currentProjectId, todoIds }); if (result.success) { await loadProjectsFromServer(); renderProjectTodos(); } } // Toast Notifications function showToast(title, msg, type = 'info') { const container = document.getElementById('toastContainer'); if (!container) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.style.display = 'flex'; toast.style.alignItems = 'flex-start'; toast.style.gap = '12px'; toast.style.padding = '14px 16px'; const icons = { success: 'โœ…', error: 'โŒ', warning: 'โš ๏ธ', info: 'โ„น๏ธ' }; toast.innerHTML = `
${icons[type] || icons.info}
${escapeHtml(title)}
${escapeHtml(msg)}
`; container.appendChild(toast); setTimeout(() => { toast.classList.add('toast-out'); setTimeout(() => toast.remove(), 300); }, 5000); } // Custom Confirm Dialog (replaces browser confirm()) function showConfirm(message, { title = '', icon = 'โš ๏ธ', yesText = 'OK', noText = '', danger = true } = {}) { return new Promise(resolve => { const modal = document.getElementById('confirmModal'); document.getElementById('confirmIcon').textContent = icon; document.getElementById('confirmTitle').textContent = title || t('modal.confirm_title') || 'Bestรคtigung'; document.getElementById('confirmMessage').textContent = message; const yesBtn = document.getElementById('confirmYesBtn'); const noBtn = document.getElementById('confirmNoBtn'); yesBtn.textContent = yesText || 'OK'; noBtn.textContent = noText || t('modal.cancel') || 'Abbrechen'; yesBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary'; function cleanup(result) { modal.classList.remove('active'); yesBtn.removeEventListener('click', onYes); noBtn.removeEventListener('click', onNo); resolve(result); } function onYes() { cleanup(true); } function onNo() { cleanup(false); } yesBtn.addEventListener('click', onYes); noBtn.addEventListener('click', onNo); modal.classList.add('active'); }); } // Member Management async function openAddMemberModal() { const project = projects.find(p => p.id === currentProjectId); if (!project) return; const result = await apiCall('getAllUsers'); if (!result.success) return; const allUsers = result.data || []; const memberIds = (project.members || []).map(m => m.userId); const available = allUsers.filter(u => !memberIds.includes(u.id)); const select = document.getElementById('addMemberUserId'); if (available.length === 0) { select.innerHTML = ``; } else { select.innerHTML = available.map(u => `` ).join(''); } document.getElementById('addMemberRole').value = 'editor'; document.getElementById('addMemberModal').classList.add('active'); } async function addMember() { const userId = document.getElementById('addMemberUserId').value; if (!userId) return; const role = document.getElementById('addMemberRole').value; const result = await apiCall('addMember', { projectId: currentProjectId, userId: parseInt(userId), role }); if (result.success) { closeModal('addMemberModal'); await loadProjectsFromServer(); const project = projects.find(p => p.id === currentProjectId); if (project) renderMembers(project); showToast(t('members.title'), result.message, 'success'); } else { showToast(t('members.add_error'), result.message || t('members.add_error'), 'error'); } } async function removeMember(projectId, userId) { if (!await showConfirm(t('members.remove_confirm'), { icon: '๐Ÿ—‘๏ธ', title: t('members.remove_btn') })) return; const result = await apiCall('removeMember', { projectId, userId }); if (result.success) { await loadProjectsFromServer(); const project = projects.find(p => p.id === projectId); if (project) renderMembers(project); showToast(t('members.title'), result.message, 'success'); } else { showToast(t('members.remove_error'), result.message || t('members.remove_error'), 'error'); } } async function updateMemberRole(projectId, userId, newRole) { const result = await apiCall('updateMemberRole', { projectId, userId, role: newRole }); if (result.success) { await loadProjectsFromServer(); const project = projects.find(p => p.id === projectId); if (project) renderMembers(project); } else { showToast(t('members.role_error'), result.message || t('members.role_error'), 'error'); } } function renderMembers(project) { const container = document.getElementById('membersList'); const addBtn = document.getElementById('addMemberBtn'); if (!container) return; const members = project.members || []; const isAdmin = currentUser.role === 'admin'; const isOwner = members.some(m => m.userId === currentUser.id && m.role === 'owner'); const canManage = isAdmin || isOwner; if (addBtn) addBtn.style.display = canManage ? '' : 'none'; const roleLabels = { owner: t('members.role_owner'), editor: t('members.role_editor'), viewer: t('members.role_viewer') }; const roleIcons = { owner: '๐Ÿ‘‘', editor: 'โœ๏ธ', viewer: '๐Ÿ‘๏ธ' }; container.innerHTML = members.map(m => { const isMe = m.userId === currentUser.id; const memberName = m.userName || m.username || `User #${m.userId}`; const nextRole = m.role === 'editor' ? 'viewer' : 'editor'; const roleColor = m.role === 'owner' ? 'var(--warning)' : m.role === 'editor' ? 'var(--primary)' : 'var(--text-muted)'; return `
${escapeHtml(memberName.charAt(0).toUpperCase())}
${escapeHtml(memberName)}${isMe ? ' ' + escapeHtml(t('members.you')) + '' : ''}
${canManage && m.role !== 'owner' ? ` ` : ` ${roleIcons[m.role]} ${escapeHtml(roleLabels[m.role])} `}
`; }).join(''); } // Manage Members Modal (standalone, works from project cards) let manageMembersProjectId = null; async function openManageMembersModal(projectId) { manageMembersProjectId = projectId; const project = projects.find(p => p.id === projectId); if (!project) return; renderManageMembersModal(project); // Load available users for the add dropdown const result = await apiCall('getAllUsers'); if (result.success) { const allUsers = result.data || []; const memberIds = (project.members || []).map(m => m.userId); const available = allUsers.filter(u => !memberIds.includes(u.id)); const select = document.getElementById('manageMemberUserId'); const addSection = document.getElementById('manageMembersAdd'); if (available.length === 0) { select.innerHTML = ``; } else { select.innerHTML = available.map(u => `` ).join(''); } } document.getElementById('manageMembersModal').classList.add('active'); } function renderManageMembersModal(project) { const container = document.getElementById('manageMembersList'); const addSection = document.getElementById('manageMembersAdd'); if (!container) return; const members = project.members || []; const isAdmin = currentUser.role === 'admin'; const isOwner = members.some(m => m.userId === currentUser.id && m.role === 'owner'); const canManage = isAdmin || isOwner; if (addSection) addSection.style.display = canManage ? '' : 'none'; const roleLabels = { owner: t('members.role_owner'), editor: t('members.role_editor'), viewer: t('members.role_viewer') }; const roleIcons = { owner: '๐Ÿ‘‘', editor: 'โœ๏ธ', viewer: '๐Ÿ‘๏ธ' }; container.innerHTML = members.map(m => { const isMe = m.userId === currentUser.id; const memberName = m.userName || `User #${m.userId}`; const nextRole = m.role === 'editor' ? 'viewer' : 'editor'; const roleColor = m.role === 'owner' ? 'var(--warning)' : m.role === 'editor' ? 'var(--primary)' : 'var(--text-muted)'; return `
${escapeHtml(memberName.charAt(0).toUpperCase())}
${escapeHtml(memberName)}${isMe ? ' ' + escapeHtml(t('members.you')) + '' : ''}
${canManage && m.role !== 'owner' ? ` ` : ` ${roleIcons[m.role]} ${escapeHtml(roleLabels[m.role])} `}
`; }).join(''); } function selectMemberRole(role) { document.getElementById('manageMemberRole').value = role; document.querySelectorAll('#manageMemberRoleToggle .member-role-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.role === role); }); } async function addMemberFromModal() { const userId = document.getElementById('manageMemberUserId').value; if (!userId || !manageMembersProjectId) return; const role = document.getElementById('manageMemberRole').value; const result = await apiCall('addMember', { projectId: manageMembersProjectId, userId: parseInt(userId), role }); if (result.success) { await loadProjectsFromServer(); const project = projects.find(p => p.id === manageMembersProjectId); if (project) { renderManageMembersModal(project); renderMembers(project); // Refresh the user dropdown openManageMembersModal(manageMembersProjectId); } showToast(t('members.title'), result.message, 'success'); } else { showToast(t('members.add_error'), result.message || t('members.add_error'), 'error'); } } async function removeMemberFromModal(projectId, userId) { if (!await showConfirm(t('members.remove_confirm'), { icon: '๐Ÿ—‘๏ธ', title: t('members.remove_btn') })) return; const result = await apiCall('removeMember', { projectId, userId }); if (result.success) { await loadProjectsFromServer(); const project = projects.find(p => p.id === projectId); if (project) { renderManageMembersModal(project); renderMembers(project); // Refresh dropdown openManageMembersModal(projectId); } showToast(t('members.title'), result.message, 'success'); } else { showToast(t('members.remove_error'), result.message || t('members.remove_error'), 'error'); } } async function updateMemberFromModal(projectId, userId, newRole) { const result = await apiCall('updateMemberRole', { projectId, userId, role: newRole }); if (result.success) { await loadProjectsFromServer(); const project = projects.find(p => p.id === projectId); if (project) { renderManageMembersModal(project); renderMembers(project); } } else { showToast(t('members.role_error'), result.message || t('members.role_error'), 'error'); } } // LDAP Functions async function loadLdapConfig() { const card = document.getElementById('ldapSettingsCard'); if (!card || currentUser.role !== 'admin') return; card.style.display = 'block'; const result = await apiCall('getLdapConfig'); if (result.success && result.data) { const c = result.data; document.getElementById('ldapServer').value = c.server || ''; document.getElementById('ldapPort').value = c.port || 389; document.getElementById('ldapBaseDn').value = c.base_dn || ''; document.getElementById('ldapUserOu').value = c.user_ou || ''; document.getElementById('ldapBindUserDn').value = c.bind_user_dn || ''; document.getElementById('ldapBindPassword').value = c.bind_password || ''; document.getElementById('ldapSearchFilter').value = c.search_filter || '(&(objectClass=user)(objectCategory=person))'; document.getElementById('ldapUsernameAttr').value = c.username_attribute || 'sAMAccountName'; document.getElementById('ldapDisplaynameAttr').value = c.display_name_attribute || 'displayName'; document.getElementById('ldapEmailAttr').value = c.email_attribute || 'mail'; document.getElementById('ldapUseTls').checked = !!c.use_tls; document.getElementById('ldapEnabled').checked = !!c.enabled; } } async function saveLdapConfig() { const config = { server: document.getElementById('ldapServer').value.trim(), port: parseInt(document.getElementById('ldapPort').value) || 389, base_dn: document.getElementById('ldapBaseDn').value.trim(), user_ou: document.getElementById('ldapUserOu').value.trim(), bind_user_dn: document.getElementById('ldapBindUserDn').value.trim(), bind_password: document.getElementById('ldapBindPassword').value, search_filter: document.getElementById('ldapSearchFilter').value.trim(), username_attribute: document.getElementById('ldapUsernameAttr').value.trim(), display_name_attribute: document.getElementById('ldapDisplaynameAttr').value.trim(), email_attribute: document.getElementById('ldapEmailAttr').value.trim(), use_tls: document.getElementById('ldapUseTls').checked, enabled: document.getElementById('ldapEnabled').checked }; const result = await apiCall('saveLdapConfig', config); if (result.success) { showToast(t('ldap.title'), result.message, 'success'); } else { showToast(t('ldap.title'), result.message || t('ldap.save_error'), 'error'); } } async function testLdapConnection() { const resultDiv = document.getElementById('ldapTestResult'); resultDiv.style.display = 'block'; resultDiv.innerHTML = '
' + t('ldap.testing') + '...
'; const result = await apiCall('testLdapConnection'); if (result.success) { resultDiv.innerHTML = '
' + escapeHtml(result.message) + '
'; } else { resultDiv.innerHTML = '
' + escapeHtml(result.message) + '
'; } } async function importLdapUsers() { if (!await showConfirm(t('ldap.import_confirm'), { icon: '๐Ÿ“ฅ', title: t('ldap.import_btn'), danger: false })) return; const result = await apiCall('importLdapUsers'); if (result.success) { const d = result.data; const detail = `${t('ldap.imported')}: ${d.imported}, ${t('ldap.updated')}: ${d.updated}, ${t('ldap.skipped')}: ${d.skipped}`; showToast(t('ldap.import_btn'), detail, 'success'); // Refresh user list const usersResult = await apiCall('getUsers'); if (usersResult.success) { users = usersResult.data; renderUsers(); } } else { showToast(t('ldap.import_btn'), result.message || t('ldap.import_error'), 'error'); } } // SMTP Config async function loadSmtpConfig() { const card = document.getElementById('smtpSettingsCard'); if (!card || currentUser.role !== 'admin') return; card.style.display = 'block'; const result = await apiCall('getSmtpConfig'); if (result.success && result.data) { const c = result.data; document.getElementById('smtpHost').value = c.host || ''; document.getElementById('smtpPort').value = c.port || 587; document.getElementById('smtpUsername').value = c.username || ''; document.getElementById('smtpPassword').value = c.password || ''; document.getElementById('smtpFromEmail').value = c.from_email || ''; document.getElementById('smtpFromName').value = c.from_name || 'TaskFlow'; document.getElementById('smtpEncryption').value = c.encryption || 'tls'; document.getElementById('smtpEnabled').checked = !!c.enabled; } } async function saveSmtpConfig() { const config = { host: document.getElementById('smtpHost').value.trim(), port: parseInt(document.getElementById('smtpPort').value) || 587, username: document.getElementById('smtpUsername').value.trim(), password: document.getElementById('smtpPassword').value, from_email: document.getElementById('smtpFromEmail').value.trim(), from_name: document.getElementById('smtpFromName').value.trim(), encryption: document.getElementById('smtpEncryption').value, enabled: document.getElementById('smtpEnabled').checked }; const result = await apiCall('saveSmtpConfig', config); if (result.success) { showToast(t('smtp.title'), result.message, 'success'); } else { showToast(t('smtp.title'), result.message || t('smtp.save_error'), 'error'); } } async function testSmtpConnection() { const email = document.getElementById('smtpTestEmail').value.trim(); if (!email) { showToast(t('smtp.title'), t('smtp.save_error'), 'warning'); return; } const resultDiv = document.getElementById('smtpTestResult'); resultDiv.style.display = 'block'; resultDiv.innerHTML = '
Sending...
'; const result = await apiCall('testSmtpConfig', { email }); if (result.success) { resultDiv.innerHTML = '
' + escapeHtml(result.message) + '
'; } else { resultDiv.innerHTML = '
' + escapeHtml(result.message) + '
'; } } // Notifications async function loadNotifications() { const result = await apiCall('getNotifications'); if (result.success && result.data && result.data.length > 0) { showPendingNotifications(result.data); } } async function showPendingNotifications(notifications) { const ids = []; notifications.forEach(n => { ids.push(n.id); if (n.type === 'project_added') { const title = t('notifications.project_added_title'); const msg = t('notifications.project_added').replace('{name}', n.projectName || ''); showToast(title, msg, 'info'); } }); if (ids.length > 0) { await apiCall('dismissNotifications', { ids }); } } // Trash / Deleted Projects async function loadDeletedProjects() { const container = document.getElementById('deletedProjectsList'); const card = document.getElementById('trashCard'); if (!container) return; const result = await apiCall('getDeletedProjects'); if (!result.success || !result.data || result.data.length === 0) { if (card) card.style.display = 'none'; return; } if (card) card.style.display = 'block'; const isAdmin = currentUser.role === 'admin'; container.innerHTML = result.data.map(p => { const deletedDate = new Date(p.deletedAt).toLocaleDateString(currentLang === 'de' ? 'de-DE' : 'en-US'); return `
๐Ÿ“
${escapeHtml(p.name)}
${escapeHtml(t('trash.deleted_by'))} ${escapeHtml(p.deletedByName)} · ${deletedDate} · ${p.daysLeft} ${escapeHtml(t('trash.days_left'))}
${isAdmin ? `` : ''}
`; }).join(''); } async function restoreProject(id) { const result = await apiCall('restoreProject', { id }); if (result.success) { showToast(t('trash.title'), t('trash.restored'), 'success'); await loadProjectsFromServer(); renderDashboard(); renderProjects(); loadDeletedProjects(); } else { showToast(t('trash.title'), result.message, 'error'); } } async function permanentDeleteProject(id) { if (!await showConfirm(t('trash.confirm_permanent'), { icon: 'โš ๏ธ', title: t('trash.delete_permanent') })) return; const result = await apiCall('permanentDeleteProject', { id }); if (result.success) { showToast(t('trash.title'), result.message, 'success'); loadDeletedProjects(); } else { showToast(t('trash.title'), result.message, 'error'); } } // Attachments function renderInlineAttachments(todo) { const atts = todo.attachments; if (!atts || atts.length === 0) return ''; const items = atts.map((att, idx) => { const url = `api.php?action=downloadAttachment&projectId=${currentProjectId}&todoId=${todo.id}&attachmentId=${encodeURIComponent(att.id)}`; const isImage = isImageFile(att.filename); if (isImage) { return ``; } return `${getFileIcon(att.filename)} ${escapeHtml(att.filename.length > 18 ? att.filename.substring(0, 15) + '...' : att.filename)}`; }).join(''); return `
๐Ÿ“Ž ${atts.length} ${escapeHtml(t('attachments.count'))}
${items}
`; } function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } function getFileIcon(filename) { const ext = (filename || '').split('.').pop().toLowerCase(); const icons = { pdf: '๐Ÿ“„', doc: '๐Ÿ“', docx: '๐Ÿ“', xls: '๐Ÿ“Š', xlsx: '๐Ÿ“Š', ppt: '๐Ÿ“Š', pptx: '๐Ÿ“Š', txt: '๐Ÿ“ƒ', csv: '๐Ÿ“ƒ', png: '๐Ÿ–ผ๏ธ', jpg: '๐Ÿ–ผ๏ธ', jpeg: '๐Ÿ–ผ๏ธ', gif: '๐Ÿ–ผ๏ธ', svg: '๐Ÿ–ผ๏ธ', webp: '๐Ÿ–ผ๏ธ', zip: '๐Ÿ“ฆ', rar: '๐Ÿ“ฆ', '7z': '๐Ÿ“ฆ', tar: '๐Ÿ“ฆ', gz: '๐Ÿ“ฆ', mp4: '๐ŸŽฌ', avi: '๐ŸŽฌ', mov: '๐ŸŽฌ', mp3: '๐ŸŽต', wav: '๐ŸŽต', }; return icons[ext] || '๐Ÿ“Ž'; } function isPreviewable(filename) { return /\.(png|jpe?g|gif|svg|webp|bmp|pdf|txt|csv|mp4|webm|ogg|mp3|wav)$/i.test(filename); } function isImageFile(filename) { return /\.(png|jpe?g|gif|svg|webp|bmp)$/i.test(filename); } function renderAttachments(attachments) { const container = document.getElementById('attachmentList'); if (!container) return; if (!attachments || attachments.length === 0) { container.innerHTML = `
${escapeHtml(t('attachments.none'))}
`; return; } const todoId = parseInt(document.getElementById('editTodoId').value); container.innerHTML = attachments.map((att, idx) => { const url = `api.php?action=downloadAttachment&projectId=${currentProjectId}&todoId=${todoId}&attachmentId=${encodeURIComponent(att.id)}`; const isImage = isImageFile(att.filename); const canPreview = isPreviewable(att.filename); return `
${isImage ? `` : `
${getFileIcon(att.filename)}
` }
${escapeHtml(att.filename)}
${formatFileSize(att.size)} · ${att.uploadedBy ? '@' + escapeHtml(att.uploadedBy) : ''} · ${new Date(att.uploadedAt).toLocaleDateString(currentLang === 'de' ? 'de-DE' : 'en-US')}
${canPreview ? `` : ''} โฌ‡๏ธ
`; }).join(''); } function initAttachmentDropZone() { const zone = document.getElementById('attachmentDropZone'); if (!zone || zone._attachmentInit) return; zone._attachmentInit = true; zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('drag-over'); }); zone.addEventListener('dragleave', () => { zone.classList.remove('drag-over'); }); zone.addEventListener('drop', (e) => { e.preventDefault(); zone.classList.remove('drag-over'); if (e.dataTransfer.files.length > 0) { handleAttachmentUpload(e.dataTransfer.files); } }); } async function handleAttachmentUpload(files) { if (!files || files.length === 0) return; const file = files[0]; const maxSize = 10 * 1024 * 1024; // 10 MB if (file.size > maxSize) { showToast(t('attachments.title'), t('attachments.too_large'), 'warning'); document.getElementById('attachmentFileInput').value = ''; return; } const todoId = parseInt(document.getElementById('editTodoId').value); const formData = new FormData(); formData.append('file', file); formData.append('projectId', currentProjectId); formData.append('todoId', todoId); try { const response = await fetch(`api.php?action=uploadAttachment&lang=${currentLang}`, { method: 'POST', body: formData }); const result = await response.json(); if (result.success) { showToast(t('attachments.title'), t('attachments.uploaded'), 'success'); await loadProjectsFromServer(); // Re-render attachments const project = projects.find(p => p.id === currentProjectId); if (project) { const todo = project.todos.find(t => t.id === todoId); if (todo) renderAttachments(todo.attachments || []); } renderProjectTodos(); if (currentProjectView === 'kanban') renderKanbanBoard(); } else { showToast(t('attachments.title'), result.message || t('attachments.upload_error'), 'error'); } } catch (error) { showToast(t('attachments.title'), t('attachments.upload_error'), 'error'); } document.getElementById('attachmentFileInput').value = ''; } async function deleteAttachment(attachmentId) { if (!await showConfirm(t('attachments.delete_confirm'), { icon: '๐Ÿ—‘๏ธ', title: t('attachments.delete') })) return; const todoId = parseInt(document.getElementById('editTodoId').value); const result = await apiCall('deleteAttachment', { projectId: currentProjectId, todoId, attachmentId }); if (result.success) { showToast(t('attachments.title'), t('attachments.deleted'), 'success'); await loadProjectsFromServer(); const project = projects.find(p => p.id === currentProjectId); if (project) { const todo = project.todos.find(t => t.id === todoId); if (todo) renderAttachments(todo.attachments || []); } renderProjectTodos(); if (currentProjectView === 'kanban') renderKanbanBoard(); } else { showToast(t('attachments.title'), result.message || t('attachments.delete_error'), 'error'); } } // Attachment Preview Lightbox function previewAttachmentDirect(projectId, todoId, idx) { const project = projects.find(p => p.id === projectId); if (!project) return; const todo = project.todos.find(t => t.id === todoId); if (!todo || !todo.attachments || !todo.attachments[idx]) return; showAttachmentPreview(todo.attachments[idx], projectId, todoId); } function previewAttachmentByIndex(idx) { const todoId = parseInt(document.getElementById('editTodoId').value); const project = projects.find(p => p.id === currentProjectId); if (!project) return; const todo = project.todos.find(t => t.id === todoId); if (!todo || !todo.attachments || !todo.attachments[idx]) return; showAttachmentPreview(todo.attachments[idx], currentProjectId, todoId); } function showAttachmentPreview(att, projectId, todoId) { const url = `api.php?action=downloadAttachment&projectId=${projectId}&todoId=${todoId}&attachmentId=${encodeURIComponent(att.id)}`; const filename = att.filename; const ext = (filename || '').split('.').pop().toLowerCase(); let overlay = document.getElementById('attachmentPreviewOverlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'attachmentPreviewOverlay'; overlay.className = 'attachment-preview-overlay'; overlay.innerHTML = `
โฌ‡๏ธ
`; document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeAttachmentPreview(); }); } overlay.querySelector('.attachment-preview-title').textContent = filename; const dlBtn = overlay.querySelector('.attachment-preview-download'); dlBtn.href = url; dlBtn.download = filename; const body = overlay.querySelector('.attachment-preview-body'); // Render preview based on file type if (isImageFile(filename)) { body.innerHTML = `${escapeHtml(filename)}`; } else if (ext === 'pdf') { body.innerHTML = ``; } else if (['mp4','webm','ogg'].includes(ext)) { body.innerHTML = ``; } else if (['mp3','wav','ogg'].includes(ext)) { body.innerHTML = `
๐ŸŽต
${escapeHtml(filename)}
`; } else if (['txt','csv','log','md'].includes(ext)) { body.innerHTML = ``; } else { body.innerHTML = `
${getFileIcon(filename)}
${escapeHtml(filename)}
${formatFileSize(att.size)}
โฌ‡๏ธ Download
`; } overlay.classList.add('active'); } function closeAttachmentPreview() { const overlay = document.getElementById('attachmentPreviewOverlay'); if (overlay) { overlay.classList.remove('active'); overlay.querySelector('.attachment-preview-body').innerHTML = ''; } } // Start app init();