/** * Projects Page JavaScript * Handles project management, including CRUD operations, search, and recycle bin */ // === State Management === let projects = []; let currentEditingProject = null; let currentContextMenuProjectId = null; // === DOM Elements === const projectsGrid = document.getElementById('projects-grid'); const emptyState = document.getElementById('empty-state'); const searchInput = document.getElementById('search-input'); const newProjectBtn = document.getElementById('new-project-btn'); const emptyStateNewProjectBtn = document.getElementById('empty-state-new-project-btn'); const recycleBinBtn = document.getElementById('recycle-bin-btn'); const projectModal = document.getElementById('project-modal'); const modalTitle = document.getElementById('modal-title'); const projectForm = document.getElementById('project-form'); const closeModalBtn = document.getElementById('close-modal-btn'); const cancelModalBtn = document.getElementById('cancel-modal-btn'); const recycleBinModal = document.getElementById('recycle-bin-modal'); const closeRecycleBinBtn = document.getElementById('close-recycle-bin-btn'); const recycleBinContent = document.getElementById('recycle-bin-content'); const contextMenu = document.getElementById('context-menu'); // === Initialization === document.addEventListener('DOMContentLoaded', async () => { await loadProjects(); setupEventListeners(); }); // === Event Listeners Setup === function setupEventListeners() { // Search functionality searchInput.addEventListener('input', (e) => { renderProjects(e.target.value); }); // Create project buttons newProjectBtn.addEventListener('click', () => openProjectModal()); emptyStateNewProjectBtn.addEventListener('click', () => openProjectModal()); // Modal close buttons closeModalBtn.addEventListener('click', closeProjectModal); cancelModalBtn.addEventListener('click', closeProjectModal); // Project form submission projectForm.addEventListener('submit', handleProjectSubmit); // Recycle bin recycleBinBtn.addEventListener('click', openRecycleBinModal); closeRecycleBinBtn.addEventListener('click', () => { recycleBinModal.style.display = 'none'; }); // Context menu actions document.getElementById('ctx-open').addEventListener('click', () => { if (currentContextMenuProjectId) { openProject(currentContextMenuProjectId); hideContextMenu(); } }); document.getElementById('ctx-edit').addEventListener('click', () => { if (currentContextMenuProjectId) { const project = projects.find(p => p.id === currentContextMenuProjectId); if (project) { openProjectModal(project); hideContextMenu(); } } }); document.getElementById('ctx-delete').addEventListener('click', () => { if (currentContextMenuProjectId) { deleteProject(currentContextMenuProjectId); hideContextMenu(); } }); // Close context menu on click outside document.addEventListener('click', (e) => { if (!contextMenu.contains(e.target)) { hideContextMenu(); } }); // Close modals on escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeProjectModal(); recycleBinModal.style.display = 'none'; hideContextMenu(); } }); // Close modals on backdrop click projectModal.addEventListener('click', (e) => { if (e.target === projectModal) { closeProjectModal(); } }); recycleBinModal.addEventListener('click', (e) => { if (e.target === recycleBinModal) { recycleBinModal.style.display = 'none'; } }); } // === API Functions === /** * Load projects from the server */ async function loadProjects() { try { projectsGrid.innerHTML = '
Loading projects...
'; const response = await fetch('/api/projects', { credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); projects = data.projects || []; renderProjects(); } catch (error) { console.error('Error loading projects:', error); showToast('Failed to load projects', 'error'); projectsGrid.innerHTML = `

Error loading projects

${escapeHtml(error.message)}

`; } } /** * Create or update a project */ async function saveProject(projectData) { try { const url = currentEditingProject ? `/api/projects/${currentEditingProject.id}` : '/api/projects'; const method = currentEditingProject ? 'PUT' : 'POST'; const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(projectData) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || `HTTP error! status: ${response.status}`); } const result = await response.json(); if (currentEditingProject) { // Update existing project in the array const index = projects.findIndex(p => p.id === currentEditingProject.id); if (index !== -1) { projects[index] = result.project; } showToast('Project updated successfully', 'success'); } else { // Add new project to the array projects.push(result.project); showToast('Project created successfully', 'success'); } renderProjects(searchInput.value); closeProjectModal(); } catch (error) { console.error('Error saving project:', error); showToast(error.message || 'Failed to save project', 'error'); throw error; } } /** * Soft delete a project */ async function deleteProject(projectId) { if (!confirm('Are you sure you want to delete this project? It will be moved to the recycle bin.')) { return; } try { const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE', credentials: 'include' }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || `HTTP error! status: ${response.status}`); } // Remove project from array projects = projects.filter(p => p.id !== projectId); renderProjects(searchInput.value); showToast('Project moved to recycle bin', 'success'); } catch (error) { console.error('Error deleting project:', error); showToast(error.message || 'Failed to delete project', 'error'); } } /** * Load deleted projects for recycle bin */ async function loadDeletedProjects() { try { const response = await fetch('/api/projects?includeDeleted=true', { credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return data.projects.filter(p => p.deletedAt) || []; } catch (error) { console.error('Error loading deleted projects:', error); showToast('Failed to load recycle bin', 'error'); return []; } } /** * Restore a project from recycle bin */ async function restoreProject(projectId) { try { const response = await fetch(`/api/projects/${projectId}/restore`, { method: 'POST', credentials: 'include' }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || `HTTP error! status: ${response.status}`); } // Reload projects and recycle bin await loadProjects(); await openRecycleBinModal(); showToast('Project restored successfully', 'success'); } catch (error) { console.error('Error restoring project:', error); showToast(error.message || 'Failed to restore project', 'error'); } } /** * Permanently delete a project */ async function permanentDeleteProject(projectId) { if (!confirm('Are you sure you want to permanently delete this project? This action cannot be undone.')) { return; } try { const response = await fetch(`/api/projects/${projectId}/permanent`, { method: 'DELETE', credentials: 'include' }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || `HTTP error! status: ${response.status}`); } // Reload recycle bin await openRecycleBinModal(); showToast('Project permanently deleted', 'success'); } catch (error) { console.error('Error permanently deleting project:', error); showToast(error.message || 'Failed to delete project', 'error'); } } // === Render Functions === /** * Render project cards based on filter */ function renderProjects(filter = '') { const filteredProjects = projects.filter(project => { const searchTerm = filter.toLowerCase(); return ( project.name.toLowerCase().includes(searchTerm) || (project.description && project.description.toLowerCase().includes(searchTerm)) || project.path.toLowerCase().includes(searchTerm) ); }); if (filteredProjects.length === 0) { projectsGrid.style.display = 'none'; emptyState.style.display = 'block'; return; } projectsGrid.style.display = 'grid'; emptyState.style.display = 'none'; projectsGrid.innerHTML = filteredProjects.map(project => createProjectCard(project)).join(''); } /** * Create HTML for a project card */ function createProjectCard(project) { const sessionsCount = project.sessionsCount || 0; const lastActivity = project.lastActivity ? formatDate(project.lastActivity) : 'No activity'; return `
${escapeHtml(project.icon || '📁')}

${escapeHtml(project.name)}

${project.description ? `

${escapeHtml(project.description)}

` : ''}
${escapeHtml(project.path)}
Sessions: ${sessionsCount}
Last activity: ${lastActivity}
`; } /** * Render recycle bin content */ async function openRecycleBinModal() { recycleBinModal.style.display = 'flex'; recycleBinContent.innerHTML = '
Loading recycle bin...
'; const deletedProjects = await loadDeletedProjects(); if (deletedProjects.length === 0) { recycleBinContent.innerHTML = `

Recycle bin is empty

`; return; } recycleBinContent.innerHTML = deletedProjects.map(project => `
${escapeHtml(project.name)}
${escapeHtml(project.path)}
`).join(''); // Add event listeners for restore and delete buttons recycleBinContent.querySelectorAll('.btn-restore').forEach(btn => { btn.addEventListener('click', () => restoreProject(parseInt(btn.dataset.projectId))); }); recycleBinContent.querySelectorAll('.btn-delete-permanent').forEach(btn => { btn.addEventListener('click', () => permanentDeleteProject(parseInt(btn.dataset.projectId))); }); } // === Navigation Functions === /** * Navigate to sessions page for a project */ function openProject(projectId) { const project = projects.find(p => p.id === projectId); if (!project) { showToast('Project not found', 'error'); return; } // Navigate to sessions page filtered by project window.location.href = `/?projectId=${projectId}`; } // === Modal Functions === /** * Open project modal for creating or editing */ function openProjectModal(project = null) { currentEditingProject = project; if (project) { modalTitle.textContent = 'Edit Project'; document.getElementById('project-name').value = project.name; document.getElementById('project-path').value = project.path; document.getElementById('project-description').value = project.description || ''; document.getElementById('project-icon').value = project.icon || '📁'; document.getElementById('project-color').value = project.color || '#4a9eff'; } else { modalTitle.textContent = 'Create Project'; projectForm.reset(); document.getElementById('project-color').value = '#4a9eff'; } projectModal.style.display = 'flex'; document.getElementById('project-name').focus(); } /** * Close project modal */ function closeProjectModal() { projectModal.style.display = 'none'; currentEditingProject = null; projectForm.reset(); } /** * Handle project form submission */ async function handleProjectSubmit(e) { e.preventDefault(); const formData = new FormData(projectForm); const projectData = { name: formData.get('name').trim(), path: formData.get('path').trim(), description: formData.get('description').trim(), icon: formData.get('icon').trim() || '📁', color: formData.get('color') || '#4a9eff' }; // Basic validation if (!projectData.name) { showToast('Project name is required', 'error'); return; } if (!projectData.path) { showToast('Project path is required', 'error'); return; } try { await saveProject(projectData); } catch (error) { // Error already handled in saveProject } } // === Context Menu Functions === /** * Show context menu for a project */ function showProjectMenu(projectId, event) { event.preventDefault(); event.stopPropagation(); currentContextMenuProjectId = projectId; // Position the menu const menuWidth = 160; const menuHeight = 120; const padding = 10; let x = event.clientX; let y = event.clientY; // Prevent menu from going off screen if (x + menuWidth > window.innerWidth - padding) { x = window.innerWidth - menuWidth - padding; } if (y + menuHeight > window.innerHeight - padding) { y = window.innerHeight - menuHeight - padding; } contextMenu.style.left = `${x}px`; contextMenu.style.top = `${y}px`; contextMenu.style.display = 'block'; } /** * Hide context menu */ function hideContextMenu() { contextMenu.style.display = 'none'; currentContextMenuProjectId = null; } // === Utility Functions === /** * Escape HTML to prevent XSS attacks */ function escapeHtml(text) { if (typeof text !== 'string') { return ''; } const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Format date string to human-readable format */ function formatDate(dateString) { if (!dateString) return 'Unknown'; const date = new Date(dateString); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; // For older dates, return formatted date const options = { month: 'short', day: 'numeric', year: 'numeric' }; return date.toLocaleDateString('en-US', options); } /** * Show toast notification */ function showToast(message, type = 'info') { // Remove existing toast if any const existingToast = document.querySelector('.toast'); if (existingToast) { existingToast.remove(); } // Create toast element const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; // Add styles Object.assign(toast.style, { position: 'fixed', bottom: '2rem', right: '2rem', padding: '1rem 1.5rem', borderRadius: '8px', backgroundColor: type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#3b82f6', color: 'white', fontWeight: '500', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', zIndex: '10000', animation: 'slideIn 0.3s ease', maxWidth: '400px', wordWrap: 'break-word' }); // Add animation keyframes if not already present if (!document.getElementById('toast-animations')) { const style = document.createElement('style'); style.id = 'toast-animations'; style.textContent = ` @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(style); } document.body.appendChild(toast); // Auto remove after 3 seconds setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease'; setTimeout(() => toast.remove(), 300); }, 3000); } // === Event Delegation for Dynamic Elements === projectsGrid.addEventListener('click', (e) => { const projectCard = e.target.closest('.project-card'); const menuBtn = e.target.closest('.project-menu-btn'); if (menuBtn) { e.stopPropagation(); const projectId = parseInt(menuBtn.dataset.projectId); showProjectMenu(projectId, e); } else if (projectCard) { const projectId = parseInt(projectCard.dataset.projectId); openProject(projectId); } }); // Make functions globally available for HTML event handlers window.loadProjects = loadProjects; window.openProjectModal = openProjectModal; window.closeProjectModal = closeProjectModal; window.openRecycleBinModal = openRecycleBinModal; window.openProject = openProject; window.deleteProject = deleteProject; window.restoreProject = restoreProject; window.permanentDeleteProject = permanentDeleteProject;