- Add loadSessionsAndProjects() to fetch sessions and projects in parallel - Store projects in window.projectsMap for quick lookup - Group sessions by projectId, separating assigned and unassigned - Render collapsible project sections with icon, name, and session count - Add toggleProjectSection() to collapse/expand sections (▼/▶) - Display project badges on sessions when assigned to a project - Unassigned sessions shown in separate section at bottom Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
792 lines
24 KiB
JavaScript
792 lines
24 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'),
|
||
fetch('/api/projects')
|
||
]);
|
||
|
||
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 grouped by project
|
||
*/
|
||
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';
|
||
|
||
// Group sessions by projectId
|
||
const grouped = {
|
||
byProject: new Map(), // projectId -> {project, sessions: []}
|
||
unassigned: [] // sessions without projectId
|
||
};
|
||
|
||
sessions.forEach(session => {
|
||
const projectId = session.metadata?.projectId;
|
||
|
||
if (projectId && window.projectsMap?.has(projectId)) {
|
||
const project = window.projectsMap.get(projectId);
|
||
|
||
if (!grouped.byProject.has(projectId)) {
|
||
grouped.byProject.set(projectId, {
|
||
project: project,
|
||
sessions: []
|
||
});
|
||
}
|
||
|
||
grouped.byProject.get(projectId).sessions.push(session);
|
||
} else {
|
||
grouped.unassigned.push(session);
|
||
}
|
||
});
|
||
|
||
// Sort sessions within each group by last activity
|
||
grouped.byProject.forEach(group => {
|
||
group.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;
|
||
});
|
||
});
|
||
|
||
grouped.unassigned.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 project sections
|
||
grouped.byProject.forEach((group, projectId) => {
|
||
const section = createProjectSection(group.project, group.sessions);
|
||
tbody.appendChild(section);
|
||
});
|
||
|
||
// Render unassigned sessions if any
|
||
if (grouped.unassigned.length > 0) {
|
||
const section = createUnassignedSection(grouped.unassigned);
|
||
tbody.appendChild(section);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
});
|
||
|
||
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;
|
||
}
|