Fix table alignment issue on landing page by rendering simple table rows instead of collapsible project sections inside the tbody. Changes: - Simplify renderSessionsGroupedByProject() to render table rows directly - Add table-layout: fixed to .projects-table for proper column widths - Sort sessions by last activity (newest first) The previous implementation was rendering div elements (project sections) inside the table tbody, which broke the table layout. Table elements only accept tr elements as direct children. Resolves "things don't align" issue on projects table. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1019 lines
31 KiB
JavaScript
1019 lines
31 KiB
JavaScript
/**
|
||
* Sessions Landing Page JavaScript
|
||
*/
|
||
|
||
// Load sessions on page load
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
checkAuth();
|
||
initializeProjectInput();
|
||
loadSessionsAndProjects();
|
||
});
|
||
|
||
// Check authentication
|
||
async function checkAuth() {
|
||
try {
|
||
const res = await fetch('/claude/api/auth/status');
|
||
|
||
if (!res.ok) {
|
||
throw new Error('Request failed');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (!data.authenticated) {
|
||
// Redirect to login if not authenticated
|
||
window.location.href = '/claude/login.html';
|
||
}
|
||
} catch (error) {
|
||
console.error('Auth check failed:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize project input field in hero section
|
||
*/
|
||
function initializeProjectInput() {
|
||
const input = document.getElementById('project-input');
|
||
const status = document.getElementById('input-status');
|
||
|
||
if (!input) return;
|
||
|
||
// Auto-focus on page load
|
||
input.focus();
|
||
|
||
input.addEventListener('input', () => {
|
||
const projectName = input.value.trim();
|
||
const hasInvalidChars = !validateProjectName(projectName);
|
||
|
||
if (hasInvalidChars && projectName.length > 0) {
|
||
status.textContent = 'Invalid characters';
|
||
status.classList.add('error');
|
||
} else {
|
||
status.textContent = '';
|
||
status.classList.remove('error');
|
||
}
|
||
});
|
||
|
||
input.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
const projectName = input.value.trim();
|
||
if (projectName && validateProjectName(projectName)) {
|
||
createProject(projectName);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create a new project and navigate to IDE
|
||
*/
|
||
async function createProject(projectName) {
|
||
try {
|
||
showLoadingOverlay('Creating project...');
|
||
|
||
const res = await fetch('/claude/api/claude/sessions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
metadata: {
|
||
type: 'chat',
|
||
source: 'web-ide',
|
||
project: projectName
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
// Minimum display time for smooth UX
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
window.location.href = `/claude/ide?session=${data.session.id}`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating project:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to create project', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load all sessions and projects, then render grouped by project
|
||
*/
|
||
async function loadSessionsAndProjects() {
|
||
const tbody = document.getElementById('projects-tbody');
|
||
const emptyState = document.getElementById('projects-empty');
|
||
const table = document.getElementById('projects-table');
|
||
|
||
if (!tbody) return;
|
||
|
||
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 40px;">Loading...</td></tr>';
|
||
|
||
try {
|
||
// Fetch both sessions and projects in parallel
|
||
const [sessionsRes, projectsRes] = await Promise.all([
|
||
fetch('/claude/api/claude/sessions', { credentials: 'include' }),
|
||
fetch('/api/projects', { credentials: 'include' })
|
||
]);
|
||
|
||
if (!sessionsRes.ok) throw new Error('Failed to load sessions');
|
||
if (!projectsRes.ok) throw new Error('Failed to load projects');
|
||
|
||
const sessionsData = await sessionsRes.json();
|
||
const projectsData = await projectsRes.json();
|
||
|
||
// Combine active and historical sessions
|
||
const allSessions = [
|
||
...(sessionsData.active || []).map(s => ({...s, type: 'active'})),
|
||
...(sessionsData.historical || []).map(s => ({...s, type: 'historical'}))
|
||
];
|
||
|
||
// Store projects in global map for quick lookup
|
||
window.projectsMap = new Map(
|
||
(projectsData.projects || []).map(p => [p.id, p])
|
||
);
|
||
|
||
renderSessionsGroupedByProject(allSessions);
|
||
|
||
} catch (error) {
|
||
console.error('Error loading sessions and projects:', error);
|
||
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #ff6b6b;">Failed to load</td></tr>';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load all sessions and render in table (legacy function for backward compatibility)
|
||
*/
|
||
async function loadSessions() {
|
||
await loadSessionsAndProjects();
|
||
}
|
||
|
||
/**
|
||
* Render sessions as a simple table
|
||
*/
|
||
function renderSessionsGroupedByProject(sessions) {
|
||
const tbody = document.getElementById('projects-tbody');
|
||
const emptyState = document.getElementById('projects-empty');
|
||
const table = document.getElementById('projects-table');
|
||
|
||
if (!tbody) return;
|
||
|
||
tbody.innerHTML = '';
|
||
|
||
if (sessions.length === 0) {
|
||
if (table) table.style.display = 'none';
|
||
if (emptyState) emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
if (table) table.style.display = 'table';
|
||
if (emptyState) emptyState.style.display = 'none';
|
||
|
||
// Sort by last activity (newest first)
|
||
sessions.sort((a, b) => {
|
||
const dateA = new Date(a.lastActivity || a.createdAt || a.created_at);
|
||
const dateB = new Date(b.lastActivity || b.createdAt || b.created_at);
|
||
return dateB - dateA;
|
||
});
|
||
|
||
// Render each session as a table row
|
||
sessions.forEach(session => {
|
||
const row = createSessionRow(session);
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Render projects table with session data (legacy function for backward compatibility)
|
||
*/
|
||
function renderProjectsTable(sessions) {
|
||
renderSessionsGroupedByProject(sessions);
|
||
}
|
||
|
||
/**
|
||
* Create a collapsible project section
|
||
*/
|
||
function createProjectSection(project, sessions) {
|
||
const container = document.createElement('div');
|
||
container.className = 'project-section';
|
||
container.dataset.projectId = project.id;
|
||
|
||
// Create section header
|
||
const header = document.createElement('div');
|
||
header.className = 'project-section-header';
|
||
header.onclick = () => toggleProjectSection(project.id);
|
||
|
||
const icon = project.icon || '📁';
|
||
const sessionCount = sessions.length;
|
||
|
||
header.innerHTML = `
|
||
<span class="project-section-toggle">▼</span>
|
||
<span class="project-section-icon">${escapeHtml(icon)}</span>
|
||
<span class="project-section-name">${escapeHtml(project.name)}</span>
|
||
<span class="project-section-count">${sessionCount} session${sessionCount !== 1 ? 's' : ''}</span>
|
||
`;
|
||
|
||
// Create sessions container
|
||
const sessionsContainer = document.createElement('div');
|
||
sessionsContainer.className = 'project-section-sessions';
|
||
|
||
// Render sessions within this project
|
||
sessions.forEach(session => {
|
||
const row = createSessionRow(session);
|
||
sessionsContainer.appendChild(row);
|
||
});
|
||
|
||
container.appendChild(header);
|
||
container.appendChild(sessionsContainer);
|
||
|
||
return container;
|
||
}
|
||
|
||
/**
|
||
* Create unassigned sessions section
|
||
*/
|
||
function createUnassignedSection(sessions) {
|
||
const container = document.createElement('div');
|
||
container.className = 'project-section unassigned-section';
|
||
|
||
// Create section header
|
||
const header = document.createElement('div');
|
||
header.className = 'project-section-header';
|
||
header.onclick = () => toggleProjectSection('unassigned');
|
||
|
||
const sessionCount = sessions.length;
|
||
|
||
header.innerHTML = `
|
||
<span class="project-section-toggle">▼</span>
|
||
<span class="project-section-icon">📄</span>
|
||
<span class="project-section-name">Unassigned Sessions</span>
|
||
<span class="project-section-count">${sessionCount} session${sessionCount !== 1 ? 's' : ''}</span>
|
||
`;
|
||
|
||
// Create sessions container
|
||
const sessionsContainer = document.createElement('div');
|
||
sessionsContainer.className = 'project-section-sessions';
|
||
|
||
// Render unassigned sessions
|
||
sessions.forEach(session => {
|
||
const row = createSessionRow(session);
|
||
sessionsContainer.appendChild(row);
|
||
});
|
||
|
||
container.appendChild(header);
|
||
container.appendChild(sessionsContainer);
|
||
|
||
return container;
|
||
}
|
||
|
||
/**
|
||
* Create a single session row
|
||
*/
|
||
function createSessionRow(session) {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'session-row';
|
||
tr.dataset.sessionId = session.id;
|
||
|
||
const projectName = getProjectName(session);
|
||
const relativeTime = getRelativeTime(session);
|
||
const status = getStatus(session);
|
||
const statusClass = getStatusClass(session);
|
||
|
||
// Check if session has a project assigned
|
||
const projectId = session.metadata?.projectId;
|
||
const hasProject = projectId && window.projectsMap?.has(projectId);
|
||
const projectBadge = hasProject ?
|
||
`<span class="session-project-badge">${escapeHtml(window.projectsMap.get(projectId).name)}</span>` : '';
|
||
|
||
tr.innerHTML = `
|
||
<td data-label="Project">
|
||
<h3 class="project-name">${escapeHtml(projectName)}</h3>
|
||
${projectBadge}
|
||
</td>
|
||
<td data-label="Last Activity">
|
||
<span class="last-activity">${relativeTime}</span>
|
||
</td>
|
||
<td data-label="Status">
|
||
<span class="status-badge ${statusClass}">${status}</span>
|
||
</td>
|
||
<td data-label="Actions">
|
||
<button class="btn-continue">Continue</button>
|
||
<button class="btn-menu" aria-label="Menu">⋮</button>
|
||
</td>
|
||
`;
|
||
|
||
tr.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.btn-menu')) {
|
||
continueToSession(session.id);
|
||
}
|
||
});
|
||
|
||
tr.querySelector('.btn-continue').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
continueToSession(session.id);
|
||
});
|
||
|
||
tr.querySelector('.btn-menu').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
showProjectMenu(e, session);
|
||
});
|
||
|
||
// Add right-click context menu
|
||
tr.addEventListener('contextmenu', (e) => {
|
||
e.preventDefault();
|
||
showSessionContextMenu(e, session.id);
|
||
});
|
||
|
||
return tr;
|
||
}
|
||
|
||
/**
|
||
* Toggle project section collapse/expand
|
||
*/
|
||
function toggleProjectSection(projectId) {
|
||
const section = projectId === 'unassigned' ?
|
||
document.querySelector('.unassigned-section') :
|
||
document.querySelector(`.project-section[data-project-id="${projectId}"]`);
|
||
|
||
if (!section) return;
|
||
|
||
const toggle = section.querySelector('.project-section-toggle');
|
||
const sessionsContainer = section.querySelector('.project-section-sessions');
|
||
|
||
if (section.classList.contains('collapsed')) {
|
||
// Expand
|
||
section.classList.remove('collapsed');
|
||
toggle.textContent = '▼';
|
||
sessionsContainer.style.display = '';
|
||
} else {
|
||
// Collapse
|
||
section.classList.add('collapsed');
|
||
toggle.textContent = '▶';
|
||
sessionsContainer.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a single project row for the table (legacy function for backward compatibility)
|
||
*/
|
||
function createProjectRow(session) {
|
||
return createSessionRow(session);
|
||
}
|
||
|
||
/**
|
||
* Extract project name from session metadata
|
||
*/
|
||
function getProjectName(session) {
|
||
return session.metadata?.project ||
|
||
session.metadata?.projectName ||
|
||
session.workingDir?.split('/').pop() ||
|
||
'Session ' + session.id.substring(0, 8);
|
||
}
|
||
|
||
/**
|
||
* Get relative time string for session
|
||
*/
|
||
function getRelativeTime(session) {
|
||
const date = new Date(session.lastActivity || session.createdAt || session.created_at);
|
||
const now = new Date();
|
||
const diffMins = Math.floor((now - date) / 60000);
|
||
const diffHours = Math.floor((now - date) / 3600000);
|
||
const diffDays = Math.floor((now - date) / 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`;
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
/**
|
||
* Get status text for session
|
||
*/
|
||
function getStatus(session) {
|
||
if (session.status === 'running') return 'Active';
|
||
return 'Done';
|
||
}
|
||
|
||
/**
|
||
* Get status CSS class for session
|
||
*/
|
||
function getStatusClass(session) {
|
||
if (session.status === 'running') return 'active';
|
||
return 'done';
|
||
}
|
||
|
||
/**
|
||
* Navigate to IDE with session
|
||
*/
|
||
async function continueToSession(sessionId) {
|
||
showLoadingOverlay('Opening workspace...');
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
window.location.href = `/claude/ide?session=${sessionId}`;
|
||
}
|
||
|
||
/**
|
||
* Show project menu dropdown
|
||
*/
|
||
function showProjectMenu(event, session) {
|
||
closeProjectMenu();
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'dropdown-menu';
|
||
menu.id = 'project-menu';
|
||
|
||
menu.innerHTML = `
|
||
<button class="dropdown-menu-item" data-action="rename">✏️ Rename</button>
|
||
<button class="dropdown-menu-item" data-action="duplicate">📋 Duplicate</button>
|
||
<button class="dropdown-menu-item danger" data-action="delete">🗑️ Delete</button>
|
||
`;
|
||
|
||
const rect = event.target.getBoundingClientRect();
|
||
menu.style.position = 'fixed';
|
||
menu.style.top = `${rect.bottom + 8}px`;
|
||
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||
|
||
document.body.appendChild(menu);
|
||
|
||
menu.querySelector('[data-action="rename"]').addEventListener('click', () => {
|
||
closeProjectMenu();
|
||
renameProject(session);
|
||
});
|
||
|
||
menu.querySelector('[data-action="duplicate"]').addEventListener('click', async () => {
|
||
closeProjectMenu();
|
||
await duplicateProject(session);
|
||
loadSessionsAndProjects();
|
||
});
|
||
|
||
menu.querySelector('[data-action="delete"]').addEventListener('click', async () => {
|
||
closeProjectMenu();
|
||
await deleteProject(session);
|
||
loadSessionsAndProjects();
|
||
});
|
||
|
||
setTimeout(() => {
|
||
document.addEventListener('click', closeProjectMenu, { once: true });
|
||
}, 10);
|
||
}
|
||
|
||
/**
|
||
* Close project menu dropdown
|
||
*/
|
||
function closeProjectMenu() {
|
||
document.getElementById('project_menu')?.remove();
|
||
}
|
||
|
||
/**
|
||
* Rename project (inline edit)
|
||
*/
|
||
function renameProject(session) {
|
||
const row = document.querySelector(`tr[data-session-id="${session.id}"]`);
|
||
if (!row) return;
|
||
|
||
const nameCell = row.querySelector('.project-name');
|
||
if (!nameCell) return;
|
||
|
||
const currentName = nameCell.textContent;
|
||
|
||
// Only block truly historical sessions (loaded from disk, not in memory)
|
||
// Active and terminated sessions in memory can be renamed
|
||
if (session.type === 'historical') {
|
||
showToast('Cannot rename historical sessions', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Create input element
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'project-name-input';
|
||
input.value = currentName;
|
||
input.maxLength = 50;
|
||
|
||
// Replace text with input
|
||
nameCell.innerHTML = '';
|
||
nameCell.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
// Handle save
|
||
const saveRename = async () => {
|
||
const newName = input.value.trim();
|
||
|
||
// Validation
|
||
if (!newName) {
|
||
nameCell.textContent = currentName;
|
||
showToast('Project name cannot be empty', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!validateProjectName(newName)) {
|
||
nameCell.textContent = currentName;
|
||
showToast('Project name contains invalid characters', 'error');
|
||
return;
|
||
}
|
||
|
||
if (newName === currentName) {
|
||
nameCell.textContent = currentName;
|
||
return;
|
||
}
|
||
|
||
// Show saving state
|
||
input.disabled = true;
|
||
input.style.opacity = '0.6';
|
||
|
||
try {
|
||
const res = await fetch(`/claude/api/claude/sessions/${session.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
metadata: {
|
||
...session.metadata,
|
||
project: newName
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json();
|
||
throw new Error(data.error || 'Failed to rename');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
nameCell.textContent = newName;
|
||
showToast('Project renamed successfully', 'success');
|
||
} else {
|
||
throw new Error(data.error || 'Failed to rename');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error renaming project:', error);
|
||
nameCell.textContent = currentName;
|
||
showToast(error.message || 'Failed to rename project', 'error');
|
||
}
|
||
};
|
||
|
||
// Handle cancel
|
||
const cancelRename = () => {
|
||
nameCell.textContent = currentName;
|
||
};
|
||
|
||
// Event listeners
|
||
input.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
input.blur(); // Trigger save on blur
|
||
} else if (e.key === 'Escape') {
|
||
cancelRename();
|
||
}
|
||
});
|
||
|
||
input.addEventListener('blur', () => {
|
||
// Small delay to allow Enter key to process first
|
||
setTimeout(() => {
|
||
if (document.activeElement !== input) {
|
||
saveRename();
|
||
}
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Duplicate project/session
|
||
*/
|
||
async function duplicateProject(session) {
|
||
try {
|
||
showLoadingOverlay('Duplicating project...');
|
||
|
||
const res = await fetch(`/claude/api/claude/sessions/${session.id}/duplicate`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
hideLoadingOverlay();
|
||
showToast('Project duplicated successfully', 'success');
|
||
} else {
|
||
throw new Error(data.error || 'Failed to duplicate');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error duplicating project:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to duplicate project', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Delete project/session with confirmation
|
||
*/
|
||
async function deleteProject(session) {
|
||
const projectName = getProjectName(session);
|
||
|
||
if (!confirm(`Are you sure you want to delete "${projectName}"? This action cannot be undone.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
showLoadingOverlay('Deleting project...');
|
||
|
||
const res = await fetch(`/claude/api/claude/sessions/${session.id}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
hideLoadingOverlay();
|
||
showToast('Project deleted successfully', 'success');
|
||
} else {
|
||
throw new Error(data.error || 'Failed to delete');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting project:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to delete project', 'error');
|
||
}
|
||
}
|
||
|
||
// Refresh sessions
|
||
function refreshSessions() {
|
||
loadSessionsAndProjects();
|
||
}
|
||
|
||
/**
|
||
* Validate project name for invalid characters
|
||
* @param {string} name - The project name to validate
|
||
* @returns {boolean} - True if valid, false if contains invalid characters
|
||
*/
|
||
function validateProjectName(name) {
|
||
const invalidChars = /[\/\\<>:"|?*]/;
|
||
return !invalidChars.test(name);
|
||
}
|
||
|
||
/**
|
||
* Show loading overlay
|
||
* @param {string} message - Optional custom message (default: "Loading...")
|
||
*/
|
||
function showLoadingOverlay(message = 'Loading...') {
|
||
let overlay = document.getElementById('loading-overlay');
|
||
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.id = 'loading-overlay';
|
||
overlay.className = 'loading-overlay';
|
||
overlay.innerHTML = `
|
||
<div class="loading-spinner"></div>
|
||
<p class="loading-text">${escapeHtml(message)}</p>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
} else {
|
||
// Update message if provided
|
||
const textElement = overlay.querySelector('.loading-text');
|
||
if (textElement) {
|
||
textElement.textContent = message;
|
||
}
|
||
}
|
||
|
||
overlay.classList.remove('hidden');
|
||
setTimeout(() => {
|
||
overlay.classList.add('visible');
|
||
}, 10);
|
||
}
|
||
|
||
/**
|
||
* Hide loading overlay
|
||
*/
|
||
function hideLoadingOverlay() {
|
||
const overlay = document.getElementById('loading-overlay');
|
||
if (overlay) {
|
||
overlay.classList.remove('visible');
|
||
setTimeout(() => {
|
||
overlay.classList.add('hidden');
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show toast notification
|
||
* @param {string} message - The message to display
|
||
* @param {string} type - The type of toast: 'success', 'error', 'info'
|
||
* @param {number} duration - Duration in milliseconds (default: 3000)
|
||
*/
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
// Remove existing toasts
|
||
const existingToasts = document.querySelectorAll('.toast-notification');
|
||
existingToasts.forEach(toast => toast.remove());
|
||
|
||
// Create toast element
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast-notification toast-${type}`;
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${getToastIcon(type)}</span>
|
||
<span class="toast-message">${escapeHtml(message)}</span>
|
||
`;
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
// Trigger animation
|
||
setTimeout(() => {
|
||
toast.classList.add('visible');
|
||
}, 10);
|
||
|
||
// Auto remove after duration
|
||
setTimeout(() => {
|
||
toast.classList.remove('visible');
|
||
setTimeout(() => {
|
||
toast.remove();
|
||
}, 300);
|
||
}, duration);
|
||
}
|
||
|
||
/**
|
||
* Get toast icon based on type
|
||
*/
|
||
function getToastIcon(type) {
|
||
const icons = {
|
||
success: '✓',
|
||
error: '✕',
|
||
info: 'ℹ',
|
||
warning: '⚠'
|
||
};
|
||
return icons[type] || icons.info;
|
||
}
|
||
|
||
/**
|
||
* Escape HTML to prevent XSS
|
||
*/
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Show session context menu for project reassignment
|
||
*/
|
||
async function showSessionContextMenu(event, sessionId) {
|
||
event.preventDefault();
|
||
hideSessionContextMenu();
|
||
|
||
// Create context menu element
|
||
const menu = document.createElement('div');
|
||
menu.id = 'sessionContextMenu';
|
||
menu.className = 'session-context-menu';
|
||
|
||
// Fetch project suggestions
|
||
let suggestions = [];
|
||
try {
|
||
const res = await fetch(`/api/projects/suggestions?sessionId=${sessionId}`, {
|
||
credentials: 'include'
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
suggestions = data.suggestions || [];
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching suggestions:', error);
|
||
}
|
||
|
||
// Build menu HTML
|
||
let menuHtml = `
|
||
<div class="context-menu-item" data-action="open">
|
||
<span class="context-menu-icon">🚀</span>
|
||
<span class="context-menu-text">Open in IDE</span>
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-label">Move to Project</div>
|
||
`;
|
||
|
||
// Add top 3 suggestions
|
||
const topSuggestions = suggestions.slice(0, 3);
|
||
topSuggestions.forEach(suggestion => {
|
||
const icon = getMatchIcon(suggestion.score);
|
||
const project = window.projectsMap?.get(suggestion.projectId);
|
||
if (project) {
|
||
menuHtml += `
|
||
<div class="context-menu-item context-menu-suggestion" data-action="move" data-project-id="${suggestion.projectId}">
|
||
<span class="context-menu-icon">${icon}</span>
|
||
<div class="context-menu-suggestion-content">
|
||
<span class="context-menu-text">${escapeHtml(project.name)}</span>
|
||
<span class="context-menu-reason">${escapeHtml(suggestion.reason)}</span>
|
||
</div>
|
||
<span class="context-menu-score">${Math.round(suggestion.score)}%</span>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
// Add "Show All Projects" option
|
||
menuHtml += `
|
||
<div class="context-menu-item" data-action="show-all">
|
||
<span class="context-menu-icon">📂</span>
|
||
<span class="context-menu-text">Show All Projects...</span>
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item" data-action="unassigned">
|
||
<span class="context-menu-icon">📄</span>
|
||
<span class="context-menu-text">Move to Unassigned</span>
|
||
</div>
|
||
`;
|
||
|
||
menu.innerHTML = menuHtml;
|
||
document.body.appendChild(menu);
|
||
|
||
// Position menu at mouse coordinates
|
||
const x = event.clientX;
|
||
const y = event.clientY;
|
||
|
||
// Ensure menu doesn't go off screen
|
||
const menuRect = menu.getBoundingClientRect();
|
||
const maxX = window.innerWidth - menuRect.width - 10;
|
||
const maxY = window.innerHeight - menuRect.height - 10;
|
||
|
||
menu.style.left = `${Math.min(x, maxX)}px`;
|
||
menu.style.top = `${Math.min(y, maxY)}px`;
|
||
|
||
// Store session ID for event handlers
|
||
menu.dataset.sessionId = sessionId;
|
||
|
||
// Add event listeners
|
||
menu.addEventListener('click', handleContextMenuClick);
|
||
|
||
// Close menu when clicking outside
|
||
setTimeout(() => {
|
||
document.addEventListener('click', hideSessionContextMenu, { once: true });
|
||
}, 10);
|
||
}
|
||
|
||
/**
|
||
* Handle context menu item clicks
|
||
*/
|
||
async function handleContextMenuClick(event) {
|
||
const menu = document.getElementById('sessionContextMenu');
|
||
if (!menu) return;
|
||
|
||
const target = event.target.closest('.context-menu-item');
|
||
if (!target) return;
|
||
|
||
const action = target.dataset.action;
|
||
const sessionId = menu.dataset.sessionId;
|
||
|
||
hideSessionContextMenu();
|
||
|
||
switch (action) {
|
||
case 'open':
|
||
continueToSession(sessionId);
|
||
break;
|
||
case 'move':
|
||
const projectId = target.dataset.projectId;
|
||
await moveSessionToProject(sessionId, projectId);
|
||
break;
|
||
case 'show-all':
|
||
showAllProjectsModal(sessionId);
|
||
break;
|
||
case 'unassigned':
|
||
await moveSessionToProject(sessionId, null);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get match icon based on score
|
||
*/
|
||
function getMatchIcon(score) {
|
||
if (score >= 90) return '🎯';
|
||
if (score >= 50) return '📂';
|
||
return '💡';
|
||
}
|
||
|
||
/**
|
||
* Move session to project
|
||
*/
|
||
async function moveSessionToProject(sessionId, projectId) {
|
||
try {
|
||
showLoadingOverlay('Moving session...');
|
||
|
||
const res = await fetch(`/api/projects/sessions/${sessionId}/move`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ projectId })
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
hideLoadingOverlay();
|
||
showToast('Session moved successfully', 'success');
|
||
await loadSessionsAndProjects();
|
||
} else {
|
||
throw new Error(data.error || 'Failed to move session');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error moving session:', error);
|
||
hideLoadingOverlay();
|
||
showToast(error.message || 'Failed to move session', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show all projects modal
|
||
*/
|
||
function showAllProjectsModal(sessionId) {
|
||
// Create modal overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.id = 'allProjectsModal';
|
||
|
||
// Create modal content
|
||
const modal = document.createElement('div');
|
||
modal.className = 'all-projects-modal';
|
||
|
||
// Build projects list
|
||
const projects = Array.from(window.projectsMap?.values() || []);
|
||
let projectsHtml = '';
|
||
|
||
if (projects.length === 0) {
|
||
projectsHtml = '<div class="no-projects">No projects available</div>';
|
||
} else {
|
||
projectsHtml = '<div class="projects-list">';
|
||
projects.forEach(project => {
|
||
projectsHtml += `
|
||
<div class="project-option" data-project-id="${project.id}">
|
||
<span class="project-option-icon">${escapeHtml(project.icon || '📁')}</span>
|
||
<span class="project-option-name">${escapeHtml(project.name)}</span>
|
||
<span class="project-option-arrow">→</span>
|
||
</div>
|
||
`;
|
||
});
|
||
projectsHtml += '</div>';
|
||
}
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-header">
|
||
<h2>All Projects</h2>
|
||
<button class="modal-close" onclick="closeAllProjectsModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="modal-info">Select a project to move this session to</p>
|
||
${projectsHtml}
|
||
</div>
|
||
`;
|
||
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
// Add click handlers
|
||
modal.querySelectorAll('.project-option').forEach(option => {
|
||
option.addEventListener('click', async () => {
|
||
const projectId = option.dataset.projectId;
|
||
closeAllProjectsModal();
|
||
await moveSessionToProject(sessionId, projectId);
|
||
});
|
||
});
|
||
|
||
// Close on overlay click
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) {
|
||
closeAllProjectsModal();
|
||
}
|
||
});
|
||
|
||
// Trigger animation
|
||
setTimeout(() => {
|
||
overlay.classList.add('visible');
|
||
}, 10);
|
||
}
|
||
|
||
/**
|
||
* Close all projects modal
|
||
*/
|
||
function closeAllProjectsModal() {
|
||
const modal = document.getElementById('allProjectsModal');
|
||
if (modal) {
|
||
modal.classList.remove('visible');
|
||
setTimeout(() => {
|
||
modal.remove();
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hide session context menu
|
||
*/
|
||
function hideSessionContextMenu() {
|
||
const menu = document.getElementById('sessionContextMenu');
|
||
if (menu) {
|
||
menu.remove();
|
||
}
|
||
}
|