feat: add projects page JavaScript functionality
Implement complete JavaScript functionality for the projects management page: - State management for projects array and current editing project - loadProjects() - Fetch projects from /api/projects - renderProjects(filter) - Render project cards with search/filter support - setupEventListeners() - Wire up all interactive elements - openProject(projectId) - Navigate to sessions page for selected project - openProjectModal(project) - Open modal for create/edit operations - closeProjectModal() - Close modal and reset form - handleProjectSubmit(e) - Validate and save project data - showProjectMenu(projectId, event) - Display context menu - deleteProject(projectId) - Soft delete with confirmation - openRecycleBinModal() - Display deleted projects - restoreProject(projectId) - Restore from recycle bin - permanentDeleteProject(projectId) - Delete forever with confirmation - escapeHtml(text) - XSS prevention for user-generated content - formatDate(dateString) - Human-readable relative timestamps - showToast(message, type) - Toast notifications with animations Features: - Async/await for all API calls - Comprehensive error handling - Real-time search filtering - Context menu for project actions - Responsive modal system - Toast notifications for user feedback - Keyboard shortcuts (Escape to close) - Click outside to close menus/modals Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
648
public/claude-ide/projects.js
Normal file
648
public/claude-ide/projects.js
Normal file
@@ -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 = '<div class="loading">Loading projects...</div>';
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>Error loading projects</h2>
|
||||||
|
<p>${escapeHtml(error.message)}</p>
|
||||||
|
<button onclick="loadProjects()" class="btn-primary">Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `
|
||||||
|
<div class="project-card" data-project-id="${project.id}">
|
||||||
|
<div class="project-card-header">
|
||||||
|
<div class="project-icon" style="background: ${escapeHtml(project.color)}20; color: ${escapeHtml(project.color)}">
|
||||||
|
${escapeHtml(project.icon || '📁')}
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<h3 class="project-name" title="${escapeHtml(project.name)}">${escapeHtml(project.name)}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="project-menu-btn" data-project-id="${project.id}" style="background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 1.2rem; padding: 0.25rem;">
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${project.description ? `<p class="project-description">${escapeHtml(project.description)}</p>` : ''}
|
||||||
|
<div class="project-path" title="${escapeHtml(project.path)}">${escapeHtml(project.path)}</div>
|
||||||
|
<div class="project-stats">
|
||||||
|
<div class="project-stat">
|
||||||
|
<span class="project-stat-label">Sessions:</span>
|
||||||
|
<span class="project-stat-value">${sessionsCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-stat">
|
||||||
|
<span class="project-stat-label">Last activity:</span>
|
||||||
|
<span class="project-stat-value">${lastActivity}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render recycle bin content
|
||||||
|
*/
|
||||||
|
async function openRecycleBinModal() {
|
||||||
|
recycleBinModal.style.display = 'flex';
|
||||||
|
recycleBinContent.innerHTML = '<div class="loading">Loading recycle bin...</div>';
|
||||||
|
|
||||||
|
const deletedProjects = await loadDeletedProjects();
|
||||||
|
|
||||||
|
if (deletedProjects.length === 0) {
|
||||||
|
recycleBinContent.innerHTML = `
|
||||||
|
<div class="recycle-bin-empty">
|
||||||
|
<p>Recycle bin is empty</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recycleBinContent.innerHTML = deletedProjects.map(project => `
|
||||||
|
<div class="recycle-bin-item" data-project-id="${project.id}">
|
||||||
|
<div class="recycle-bin-item-info">
|
||||||
|
<div class="recycle-bin-item-name">${escapeHtml(project.name)}</div>
|
||||||
|
<div class="recycle-bin-item-path">${escapeHtml(project.path)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="recycle-bin-item-actions">
|
||||||
|
<button class="btn-restore" data-project-id="${project.id}">Restore</button>
|
||||||
|
<button class="btn-delete-permanent" data-project-id="${project.id}">Delete Forever</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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;
|
||||||
Reference in New Issue
Block a user