diff --git a/public/claude-ide/projects.js b/public/claude-ide/projects.js new file mode 100644 index 00000000..c7330292 --- /dev/null +++ b/public/claude-ide/projects.js @@ -0,0 +1,648 @@ +/** + * 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'); + 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' + }, + 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' + }); + + 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'); + 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' + }); + + 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' + }); + + 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;