Initial commit: Obsidian Web Interface for Claude Code
- Full IDE with terminal integration using xterm.js - Session management with local and web sessions - HTML preview functionality - Multi-terminal support with session picker Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
608
public/claude-ide/sessions-landing.js
Normal file
608
public/claude-ide/sessions-landing.js
Normal file
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* Sessions Landing Page JavaScript
|
||||
*/
|
||||
|
||||
// Load sessions on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuth();
|
||||
initializeProjectInput();
|
||||
loadSessions();
|
||||
});
|
||||
|
||||
// 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 render in table
|
||||
*/
|
||||
async function loadSessions() {
|
||||
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 {
|
||||
const res = await fetch('/claude/api/claude/sessions');
|
||||
|
||||
if (!res.ok) throw new Error('Request failed');
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const allSessions = [
|
||||
...(data.active || []).map(s => ({...s, type: 'active'})),
|
||||
...(data.historical || []).map(s => ({...s, type: 'historical'}))
|
||||
];
|
||||
|
||||
renderProjectsTable(allSessions);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading sessions:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #ff6b6b;">Failed to load</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render projects table with session data
|
||||
*/
|
||||
function renderProjectsTable(sessions) {
|
||||
const tbody = document.getElementById('projects-tbody');
|
||||
const emptyState = document.getElementById('projects-empty');
|
||||
const table = document.getElementById('projects-table');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
sessions.forEach(session => {
|
||||
const row = createProjectRow(session);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single project row for the table
|
||||
*/
|
||||
function createProjectRow(session) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.sessionId = session.id;
|
||||
|
||||
const projectName = getProjectName(session);
|
||||
const relativeTime = getRelativeTime(session);
|
||||
const status = getStatus(session);
|
||||
const statusClass = getStatusClass(session);
|
||||
|
||||
tr.innerHTML = `
|
||||
<td data-label="Project">
|
||||
<h3 class="project-name">${escapeHtml(projectName)}</h3>
|
||||
</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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
loadSessions();
|
||||
});
|
||||
|
||||
menu.querySelector('[data-action="delete"]').addEventListener('click', async () => {
|
||||
closeProjectMenu();
|
||||
await deleteProject(session);
|
||||
loadSessions();
|
||||
});
|
||||
|
||||
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() {
|
||||
loadSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user