// Excel Management System Client Logic
document.addEventListener('DOMContentLoaded', () => {
// State
let currentFileId = null;
let currentFileName = '';
let currentSheetNames = [];
let currentActiveSheet = '';
let currentSheetRows = [];
let currentTotalCols = 0;
let renderedRowCount = 0;
let isDirty = false;
const CHUNK_SIZE = 200;
// DOM Elements - Navigation & Header
const brandHome = document.getElementById('brand-home');
const viewerNavInfo = document.getElementById('viewer-nav-info');
const currentFilenameText = document.getElementById('current-filename-text');
const btnBackDashboard = document.getElementById('btn-back-dashboard');
const btnDownloadFile = document.getElementById('btn-download-file');
const notificationBar = document.getElementById('notification-bar');
const notificationMessage = document.getElementById('notification-message');
const notificationClose = document.getElementById('notification-close');
// DOM Elements - Views
const dashboardView = document.getElementById('dashboard-view');
const spreadsheetView = document.getElementById('spreadsheet-view');
// DOM Elements - Dashboard
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('file-input');
const uploadProgress = document.getElementById('upload-progress');
const filesGrid = document.getElementById('files-grid');
const filesEmpty = document.getElementById('files-empty');
const filesLoading = document.getElementById('files-loading');
const fileCountBadge = document.getElementById('file-count-badge');
// DOM Elements - Spreadsheet Viewer
const btnViewerBack = document.getElementById('btn-viewer-back');
const btnViewerDownload = document.getElementById('btn-viewer-download');
const btnSaveSheet = document.getElementById('btn-save-sheet');
const btnAddRow = document.getElementById('btn-add-row');
const unsavedBadge = document.getElementById('unsaved-badge');
const sheetFileTitle = document.getElementById('sheet-file-title');
const sheetStats = document.getElementById('sheet-stats');
const sheetTabsList = document.getElementById('sheet-tabs-list');
const gridViewport = document.getElementById('grid-viewport');
const gridLoading = document.getElementById('grid-loading');
const sheetTable = document.getElementById('spreadsheet-table');
const sheetThead = document.getElementById('sheet-thead');
const sheetTbody = document.getElementById('sheet-tbody');
const sheetEmptyMsg = document.getElementById('sheet-empty-msg');
const renderedRowsInfo = document.getElementById('rendered-rows-info');
// ==========================================
// Helper Functions
// ==========================================
function showNotification(message, isSuccess = false) {
notificationBar.className = 'notification-bar' + (isSuccess ? ' success' : '');
notificationMessage.textContent = message;
notificationBar.style.display = 'block';
if (isSuccess) {
setTimeout(() => {
hideNotification();
}, 5000);
}
}
function hideNotification() {
notificationBar.style.display = 'none';
}
notificationClose.addEventListener('click', hideNotification);
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDate(isoString) {
if (!isoString) return 'Just now';
const date = new Date(isoString);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Convert zero-indexed column number to Excel column letter (0 -> 'A', 25 -> 'Z', 26 -> 'AA')
function getExcelColLetter(index) {
let letter = '';
let temp = index;
while (temp >= 0) {
letter = String.fromCharCode((temp % 26) + 65) + letter;
temp = Math.floor(temp / 26) - 1;
}
return letter;
}
// ==========================================
// Navigation & View Switching
// ==========================================
function navigateToDashboard() {
if (isDirty) {
if (!confirm('You have unsaved changes in the spreadsheet. Do you want to leave without saving?')) {
return;
}
}
isDirty = false;
unsavedBadge.style.display = 'none';
currentFileId = null;
currentFileName = '';
currentSheetRows = [];
renderedRowCount = 0;
// Reset views
spreadsheetView.style.display = 'none';
spreadsheetView.classList.remove('active');
dashboardView.style.display = 'flex';
dashboardView.classList.add('active');
// Reset top nav
viewerNavInfo.style.display = 'none';
btnBackDashboard.style.display = 'none';
btnDownloadFile.style.display = 'none';
window.location.hash = '';
loadFilesList();
}
function navigateToSpreadsheet(fileId, fileName) {
currentFileId = fileId;
currentFileName = fileName;
// Update views
dashboardView.style.display = 'none';
dashboardView.classList.remove('active');
spreadsheetView.style.display = 'flex';
spreadsheetView.classList.add('active');
// Update top nav
currentFilenameText.textContent = fileName;
sheetFileTitle.textContent = fileName;
viewerNavInfo.style.display = 'inline-flex';
btnBackDashboard.style.display = 'inline-flex';
btnDownloadFile.style.display = 'inline-flex';
window.location.hash = `#file=${encodeURIComponent(fileId)}`;
loadFileDetailsAndSheet(fileId);
}
brandHome.addEventListener('click', navigateToDashboard);
btnBackDashboard.addEventListener('click', navigateToDashboard);
btnViewerBack.addEventListener('click', navigateToDashboard);
// Download Action
function triggerDownload() {
if (!currentFileId) return;
window.location.href = `/api/files/${encodeURIComponent(currentFileId)}/download`;
}
btnDownloadFile.addEventListener('click', triggerDownload);
btnViewerDownload.addEventListener('click', triggerDownload);
// ==========================================
// Dashboard & File Listing
// ==========================================
async function loadFilesList() {
filesLoading.style.display = 'flex';
filesEmpty.style.display = 'none';
filesGrid.innerHTML = '';
try {
const res = await fetch('/api/files');
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to fetch files');
}
const files = data.files || [];
fileCountBadge.textContent = `${files.length} file${files.length === 1 ? '' : 's'}`;
if (files.length === 0) {
filesEmpty.style.display = 'block';
} else {
renderFileCards(files);
}
} catch (err) {
showNotification('Error loading files: ' + err.message);
} finally {
filesLoading.style.display = 'none';
}
}
function renderFileCards(files) {
filesGrid.innerHTML = '';
files.forEach(file => {
const card = document.createElement('div');
card.className = 'file-card';
const sheetCount = file.sheetNames ? file.sheetNames.length : 1;
card.innerHTML = `
`;
// Click on card or "Open" button opens the spreadsheet
card.addEventListener('click', (e) => {
// Prevent trigger if download button was clicked
if (e.target.closest('.btn-card-download')) {
e.stopPropagation();
window.location.href = `/api/files/${encodeURIComponent(file.id)}/download`;
return;
}
navigateToSpreadsheet(file.id, file.originalName);
});
filesGrid.appendChild(card);
});
}
// ==========================================
// File Upload Handling
// ==========================================
function validateExcelFile(file) {
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.xlsx') || name.endsWith('.xls');
}
async function handleFileUpload(file) {
if (!file) return;
if (!validateExcelFile(file)) {
showNotification('Invalid file type. Please upload a valid Excel file (.xlsx or .xls).');
return;
}
hideNotification();
uploadProgress.style.display = 'flex';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to upload Excel file.');
}
showNotification(`"${data.file.originalName}" uploaded successfully!`, true);
// Refresh files list
await loadFilesList();
} catch (err) {
showNotification('Upload Error: ' + err.message);
} finally {
uploadProgress.style.display = 'none';
fileInput.value = '';
}
}
fileInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) {
handleFileUpload(e.target.files[0]);
}
});
// Drag and Drop
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('drag-over');
});
});
dropzone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
if (dt && dt.files && dt.files[0]) {
handleFileUpload(dt.files[0]);
}
});
// ==========================================
// Spreadsheet Viewer & Worksheet Switching
// ==========================================
async function loadFileDetailsAndSheet(fileId, targetSheet = null) {
if (isDirty) {
if (!confirm('You have unsaved changes in this sheet. Switch without saving?')) {
return;
}
}
isDirty = false;
unsavedBadge.style.display = 'none';
gridLoading.style.display = 'flex';
sheetEmptyMsg.style.display = 'none';
sheetThead.innerHTML = '';
sheetTbody.innerHTML = '';
sheetTabsList.innerHTML = '';
renderedRowCount = 0;
renderedRowsInfo.textContent = 'Loading...';
try {
let url = `/api/files/${encodeURIComponent(fileId)}/sheet`;
if (targetSheet) {
url += `?name=${encodeURIComponent(targetSheet)}`;
}
const res = await fetch(url);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to load sheet data.');
}
currentSheetNames = data.sheetNames || [];
currentActiveSheet = data.sheetName;
currentSheetRows = data.rows || [];
currentTotalCols = data.totalCols;
// Render Sheet Tabs
renderSheetTabs(currentSheetNames, currentActiveSheet);
// Render Grid
renderSpreadsheetGrid(currentSheetRows, data.totalCols);
// Update stats badge
sheetStats.textContent = `Sheet: ${currentActiveSheet} (${data.totalRows} rows × ${data.totalCols} cols)`;
} catch (err) {
showNotification('Error loading spreadsheet: ' + err.message);
} finally {
gridLoading.style.display = 'none';
}
}
function renderSheetTabs(sheetNames, activeSheet) {
sheetTabsList.innerHTML = '';
sheetNames.forEach(name => {
const tab = document.createElement('button');
tab.className = 'sheet-tab' + (name === activeSheet ? ' active' : '');
tab.innerHTML = `
${escapeHtml(name)}
`;
tab.addEventListener('click', () => {
if (name !== currentActiveSheet) {
loadFileDetailsAndSheet(currentFileId, name);
}
});
sheetTabsList.appendChild(tab);
});
}
function renderSpreadsheetGrid(rows, totalCols) {
sheetThead.innerHTML = '';
sheetTbody.innerHTML = '';
renderedRowCount = 0;
if (!rows || rows.length === 0 || totalCols === 0) {
sheetEmptyMsg.style.display = 'block';
renderedRowsInfo.textContent = '0 rows';
return;
}
sheetEmptyMsg.style.display = 'none';
// 1. Build Header Row with Excel Column Letters (A, B, C...)
const headerRow = document.createElement('tr');
// Corner cell (top-left intersection for row & column headers)
const cornerCell = document.createElement('th');
cornerCell.className = 'corner-cell';
cornerCell.textContent = '';
headerRow.appendChild(cornerCell);
// Column letters: A, B, C...
for (let c = 0; c < totalCols; c++) {
const th = document.createElement('th');
th.textContent = getExcelColLetter(c);
th.title = `Column ${getExcelColLetter(c)}`;
headerRow.appendChild(th);
}
sheetThead.appendChild(headerRow);
// 2. Render initial chunk of rows
appendRowChunk();
// Reset scroll position to top
gridViewport.scrollTop = 0;
gridViewport.scrollLeft = 0;
}
// Progressive rendering for large datasets
function appendRowChunk() {
if (renderedRowCount >= currentSheetRows.length) return;
const nextBatchEnd = Math.min(renderedRowCount + CHUNK_SIZE, currentSheetRows.length);
const fragment = document.createDocumentFragment();
for (let r = renderedRowCount; r < nextBatchEnd; r++) {
const rowData = currentSheetRows[r];
const tr = document.createElement('tr');
// Left-hand row number cell (1-indexed)
const rowHeader = document.createElement('th');
rowHeader.className = 'row-header';
rowHeader.textContent = r + 1;
tr.appendChild(rowHeader);
// Data cells (editable)
for (let c = 0; c < rowData.length; c++) {
const td = document.createElement('td');
const val = rowData[c];
td.textContent = (val !== undefined && val !== null) ? val : '';
td.className = 'editable-cell';
td.contentEditable = 'true';
td.spellcheck = false;
td.dataset.row = r;
td.dataset.col = c;
// Track edits
td.addEventListener('input', () => {
const newVal = td.innerText;
currentSheetRows[r][c] = newVal;
td.classList.add('cell-modified');
if (!isDirty) {
isDirty = true;
unsavedBadge.style.display = 'inline-flex';
}
});
// Navigation on Enter
td.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const nextRow = r + 1;
const nextCell = sheetTbody.querySelector(`td[data-row="${nextRow}"][data-col="${c}"]`);
if (nextCell) {
nextCell.focus();
} else {
td.blur();
}
}
});
tr.appendChild(td);
}
fragment.appendChild(tr);
}
sheetTbody.appendChild(fragment);
renderedRowCount = nextBatchEnd;
// Update row count footer
renderedRowsInfo.textContent = `Showing ${renderedRowCount} of ${currentSheetRows.length} rows`;
}
// Add Row Button Handler
btnAddRow.addEventListener('click', () => {
if (!currentFileId) return;
const cols = currentTotalCols > 0 ? currentTotalCols : (currentSheetRows[0] ? currentSheetRows[0].length : 5);
const newRow = new Array(cols).fill('');
const rowIndex = currentSheetRows.length;
currentSheetRows.push(newRow);
const tr = document.createElement('tr');
const rowHeader = document.createElement('th');
rowHeader.className = 'row-header';
rowHeader.textContent = rowIndex + 1;
tr.appendChild(rowHeader);
for (let c = 0; c < cols; c++) {
const td = document.createElement('td');
td.textContent = '';
td.className = 'editable-cell cell-modified';
td.contentEditable = 'true';
td.spellcheck = false;
td.dataset.row = rowIndex;
td.dataset.col = c;
td.addEventListener('input', () => {
currentSheetRows[rowIndex][c] = td.innerText;
td.classList.add('cell-modified');
if (!isDirty) {
isDirty = true;
unsavedBadge.style.display = 'inline-flex';
}
});
td.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
td.blur();
}
});
tr.appendChild(td);
}
sheetTbody.appendChild(tr);
renderedRowCount++;
sheetStats.textContent = `Sheet: ${currentActiveSheet} (${currentSheetRows.length} rows × ${cols} cols)`;
renderedRowsInfo.textContent = `Showing ${renderedRowCount} of ${currentSheetRows.length} rows`;
if (!isDirty) {
isDirty = true;
unsavedBadge.style.display = 'inline-flex';
}
// Scroll to bottom & focus the new row's first cell
gridViewport.scrollTop = gridViewport.scrollHeight;
const firstCell = tr.querySelector('td');
if (firstCell) firstCell.focus();
});
// Save Sheet Button Handler
btnSaveSheet.addEventListener('click', async () => {
if (!currentFileId || !currentActiveSheet) return;
const originalBtnHtml = btnSaveSheet.innerHTML;
btnSaveSheet.disabled = true;
btnSaveSheet.innerHTML = `
Saving...
`;
try {
const res = await fetch(`/api/files/${encodeURIComponent(currentFileId)}/sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sheetName: currentActiveSheet,
rows: currentSheetRows
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to save worksheet.');
}
isDirty = false;
unsavedBadge.style.display = 'none';
// Clear modified styling on all cells
sheetTbody.querySelectorAll('.cell-modified').forEach(cell => {
cell.classList.remove('cell-modified');
});
showNotification(`"${currentActiveSheet}" saved successfully to Excel file!`, true);
} catch (err) {
showNotification('Save Error: ' + err.message);
} finally {
btnSaveSheet.disabled = false;
btnSaveSheet.innerHTML = originalBtnHtml;
}
});
// Infinite scroll listener on table viewport
gridViewport.addEventListener('scroll', () => {
if (renderedRowCount >= currentSheetRows.length) return;
const scrollBottom = gridViewport.scrollHeight - gridViewport.scrollTop - gridViewport.clientHeight;
// Trigger next chunk when user scrolls within 300px of bottom
if (scrollBottom < 300) {
appendRowChunk();
}
});
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Check URL hash on startup for direct link support
function checkUrlHash() {
const hash = window.location.hash;
if (hash && hash.startsWith('#file=')) {
const fileId = decodeURIComponent(hash.substring(6));
if (fileId) {
navigateToSpreadsheet(fileId, fileId);
return;
}
}
loadFilesList();
}
window.addEventListener('popstate', () => {
checkUrlHash();
});
// Initial load
checkUrlHash();
});