/**
* 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 `
${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 = `
`;
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;