693 lines
22 KiB
JavaScript
693 lines
22 KiB
JavaScript
// 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 = `
|
||
<div class="file-card-main">
|
||
<div class="file-badge-icon">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||
<polyline points="14 2 14 8 20 8"></polyline>
|
||
<line x1="8" y1="13" x2="16" y2="13"></line>
|
||
<line x1="8" y1="17" x2="16" y2="17"></line>
|
||
</svg>
|
||
</div>
|
||
<div class="file-details">
|
||
<div class="file-card-title" title="${file.originalName}">${file.originalName}</div>
|
||
<div class="file-meta-row">
|
||
<span class="file-meta-item">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||
</svg>
|
||
${formatBytes(file.size)}
|
||
</span>
|
||
<span>•</span>
|
||
<span class="file-meta-item">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<polyline points="12 6 12 12 16 14"></polyline>
|
||
</svg>
|
||
${formatDate(file.uploadedAt)}
|
||
</span>
|
||
<span>•</span>
|
||
<span class="file-meta-item">${sheetCount} sheet${sheetCount > 1 ? 's' : ''}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="file-card-actions">
|
||
<button class="btn btn-sm btn-primary btn-open-sheet">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||
<circle cx="12" cy="12" r="3"></circle>
|
||
</svg>
|
||
Open Spreadsheet
|
||
</button>
|
||
<button class="btn btn-sm btn-secondary btn-card-download" title="Download original file">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="7 10 12 15 17 10"></polyline>
|
||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
// 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 = `
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="3" y1="3" x2="21" y2="3"></line>
|
||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||
<line x1="3" y1="15" x2="21" y2="15"></line>
|
||
<line x1="3" y1="21" x2="21" y2="21"></line>
|
||
</svg>
|
||
<span>${escapeHtml(name)}</span>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="spinner" style="width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:4px;"></div>
|
||
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, '"')
|
||
.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();
|
||
});
|
||
|