perf: optimize UI rendering and event lifecycle

This commit is contained in:
Diocrafts
2026-02-18 10:53:43 +01:00
parent f661282962
commit 0d7c8bc019
28 changed files with 1832 additions and 766 deletions
+192 -215
View File
@@ -46,6 +46,172 @@ const elements = {
// Will be populated on initialization
};
// Upload dropdown listener state (prevents accumulated listeners)
let uploadDropdownDocumentClickHandler = null;
let uploadDropdownBindingsController = null;
let actionsBarDelegationBound = false;
const ACTIONS_BAR_TEMPLATES = {
files: `
<div class="action-buttons">
<div class="upload-dropdown" id="upload-dropdown">
<button class="btn btn-primary" id="upload-btn">
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
<span data-i18n="actions.upload">Upload</span>
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
</button>
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
<button class="upload-dropdown-item" id="upload-files-btn">
<i class="fas fa-file"></i>
<span data-i18n="actions.upload_files">Upload files</span>
</button>
<button class="upload-dropdown-item" id="upload-folder-btn">
<i class="fas fa-folder-open"></i>
<span data-i18n="actions.upload_folder">Upload folder</span>
</button>
</div>
</div>
<button class="btn btn-secondary" id="new-folder-btn">
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i>
<span data-i18n="actions.new_folder">New folder</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`,
trash: `
<div class="action-buttons">
<button class="btn btn-danger" id="empty-trash-btn">
<i class="fas fa-trash-alt"></i>
<span data-i18n="trash.empty_trash">Empty trash</span>
</button>
</div>
`,
favorites: `
<div class="action-buttons"></div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`,
recent: `
<div class="action-buttons">
<button class="btn btn-secondary" id="clear-recent-btn">
<i class="fas fa-broom" style="margin-right: 5px;"></i>
<span data-i18n="actions.clear_recent">Clear recent</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`
};
function setActionsBarMode(mode, force = false) {
if (!elements.actionsBar) return;
if (mode === 'hidden') {
elements.actionsBar.style.display = 'none';
elements.actionsBar.dataset.mode = 'hidden';
return;
}
if (!force && elements.actionsBar.dataset.mode === mode) {
return;
}
const html = ACTIONS_BAR_TEMPLATES[mode];
if (!html) return;
elements.actionsBar.innerHTML = html;
elements.actionsBar.style.display = 'flex';
elements.actionsBar.dataset.mode = mode;
// Refresh cached action elements after rebuild
elements.uploadBtn = document.getElementById('upload-btn');
elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
if (window.i18n && window.i18n.translateElement) {
window.i18n.translateElement(elements.actionsBar);
}
if (mode === 'files') {
setupUploadDropdown();
}
}
function setupActionsBarDelegation() {
if (actionsBarDelegationBound || !elements.actionsBar) return;
actionsBarDelegationBound = true;
elements.actionsBar.addEventListener('click', async (e) => {
const btn = e.target.closest('button');
if (!btn) return;
switch (btn.id) {
case 'upload-files-btn': {
e.stopPropagation();
const menu = document.getElementById('upload-dropdown-menu');
if (menu) menu.classList.remove('show');
if (elements.fileInput) elements.fileInput.click();
break;
}
case 'upload-folder-btn': {
e.stopPropagation();
const menu = document.getElementById('upload-dropdown-menu');
if (menu) menu.classList.remove('show');
const folderInput = document.getElementById('folder-input');
if (folderInput) folderInput.click();
break;
}
case 'new-folder-btn': {
const folderName = await window.Modal.promptNewFolder();
if (folderName) {
fileOps.createFolder(folderName);
}
break;
}
case 'grid-view-btn':
ui.switchToGridView();
break;
case 'list-view-btn':
ui.switchToListView();
break;
case 'empty-trash-btn':
if (await fileOps.emptyTrash()) {
loadTrashItems();
}
break;
case 'clear-recent-btn':
if (window.recent) {
window.recent.clearRecentFiles();
window.recent.displayRecentFiles();
window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
}
break;
default:
break;
}
});
}
/**
* Initialize the application
*/
@@ -328,13 +494,17 @@ async function fetchAppVersion() {
* Handles opening/closing the dropdown and triggering file/folder inputs
*/
function setupUploadDropdown() {
const dropdown = document.getElementById('upload-dropdown');
const uploadBtn = document.getElementById('upload-btn');
const menu = document.getElementById('upload-dropdown-menu');
const uploadFilesBtn = document.getElementById('upload-files-btn');
const uploadFolderBtn = document.getElementById('upload-folder-btn');
if (!uploadBtn || !menu) return;
// Abort any previous local bindings (safe across repeated/rebuilt UI)
if (uploadDropdownBindingsController) {
uploadDropdownBindingsController.abort();
}
uploadDropdownBindingsController = new AbortController();
const signal = uploadDropdownBindingsController.signal;
// Toggle dropdown on button click
uploadBtn.addEventListener('click', (e) => {
@@ -345,33 +515,18 @@ function setupUploadDropdown() {
if (!isOpen) {
menu.classList.add('show');
}
});
// Upload files option
if (uploadFilesBtn) {
uploadFilesBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.remove('show');
elements.fileInput.click();
});
}
// Upload folder option
if (uploadFolderBtn) {
uploadFolderBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.remove('show');
const folderInput = document.getElementById('folder-input');
if (folderInput) {
folderInput.click();
}
});
}
}, { signal });
// Close dropdown when clicking outside
document.addEventListener('click', () => {
// remove+add stable handler: guarantees exactly one global listener
if (uploadDropdownDocumentClickHandler) {
document.removeEventListener('click', uploadDropdownDocumentClickHandler);
}
uploadDropdownDocumentClickHandler = (e) => {
if (e.target.closest('#upload-dropdown')) return;
document.querySelectorAll('.upload-dropdown-menu.show').forEach(m => m.classList.remove('show'));
});
};
document.addEventListener('click', uploadDropdownDocumentClickHandler);
}
/**
@@ -435,6 +590,10 @@ function setupEventListeners() {
// Upload dropdown
setupUploadDropdown();
setupActionsBarDelegation();
if (elements.actionsBar) {
elements.actionsBar.dataset.mode = 'files';
}
// File input
elements.fileInput.addEventListener('change', (e) => {
@@ -455,18 +614,6 @@ function setupEventListeners() {
});
}
// New folder button
elements.newFolderBtn.addEventListener('click', async () => {
const folderName = await window.Modal.promptNewFolder();
if (folderName) {
fileOps.createFolder(folderName);
}
});
// View toggle
elements.gridViewBtn.addEventListener('click', ui.switchToGridView);
elements.listViewBtn.addEventListener('click', ui.switchToListView);
// Sidebar navigation
elements.navItems.forEach(item => {
item.addEventListener('click', () => {
@@ -529,22 +676,7 @@ function setupEventListeners() {
// Update UI
elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.trash') : 'Trash';
elements.pageTitle.setAttribute('data-i18n', 'nav.trash');
elements.actionsBar.innerHTML = `
<div class="action-buttons">
<button class="btn btn-danger" id="empty-trash-btn">
<i class="fas fa-trash-alt"></i>
<span>${window.i18n ? window.i18n.t('trash.empty_trash') : 'Empty trash'}</span>
</button>
</div>
`;
elements.actionsBar.style.display = 'flex';
// Add event listener to empty trash button
document.getElementById('empty-trash-btn').addEventListener('click', async () => {
if (await fileOps.emptyTrash()) {
loadTrashItems();
}
});
setActionsBarMode('trash');
// Load trash items
loadTrashItems();
@@ -572,39 +704,7 @@ function setupEventListeners() {
// Reset UI
elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.files') : 'Files';
elements.actionsBar.innerHTML = `
<div class="action-buttons">
<div class="upload-dropdown" id="upload-dropdown">
<button class="btn btn-primary" id="upload-btn">
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
<span data-i18n="actions.upload">Upload</span>
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
</button>
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
<button class="upload-dropdown-item" id="upload-files-btn">
<i class="fas fa-file"></i>
<span data-i18n="actions.upload_files">Upload files</span>
</button>
<button class="upload-dropdown-item" id="upload-folder-btn">
<i class="fas fa-folder-open"></i>
<span data-i18n="actions.upload_folder">Upload folder</span>
</button>
</div>
</div>
<button class="btn btn-secondary" id="new-folder-btn">
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i> <span data-i18n="actions.new_folder">New folder</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`;
elements.actionsBar.style.display = 'flex';
setActionsBarMode('files');
// Show files containers
const filesGrid = document.getElementById('files-grid');
@@ -612,25 +712,6 @@ function setupEventListeners() {
if (filesGrid) filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none';
if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none';
// Restore event listeners
setupUploadDropdown();
document.getElementById('new-folder-btn').addEventListener('click', async () => {
const folderName = await window.Modal.promptNewFolder();
if (folderName) {
fileOps.createFolder(folderName);
}
});
document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView);
document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView);
// Restore cached elements
elements.uploadBtn = document.getElementById('upload-btn');
elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
// Load regular files
app.currentPath = '';
ui.updateBreadcrumb('');
@@ -1188,58 +1269,7 @@ function switchToFilesView() {
}
// Reset UI
elements.actionsBar.innerHTML = `
<div class="action-buttons">
<div class="upload-dropdown" id="upload-dropdown">
<button class="btn btn-primary" id="upload-btn">
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
<span data-i18n="actions.upload">Upload</span>
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
</button>
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
<button class="upload-dropdown-item" id="upload-files-btn">
<i class="fas fa-file"></i>
<span data-i18n="actions.upload_files">Upload files</span>
</button>
<button class="upload-dropdown-item" id="upload-folder-btn">
<i class="fas fa-folder-open"></i>
<span data-i18n="actions.upload_folder">Upload folder</span>
</button>
</div>
</div>
<button class="btn btn-secondary" id="new-folder-btn">
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i> <span data-i18n="actions.new_folder">New folder</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`;
elements.actionsBar.style.display = 'flex';
// Restore event listeners
setupUploadDropdown();
document.getElementById('new-folder-btn').addEventListener('click', async () => {
const folderName = await window.Modal.promptNewFolder();
if (folderName) {
fileOps.createFolder(folderName);
}
});
document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView);
document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView);
// Restore cached elements
elements.uploadBtn = document.getElementById('upload-btn');
elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
setActionsBarMode('files');
// Hide shared view if it exists
if (window.sharedView) {
@@ -1302,28 +1332,7 @@ function switchToFavoritesView() {
}
// Configure actions bar for favorites view
elements.actionsBar.innerHTML = `
<div class="action-buttons">
<!-- No actions needed for favorites view -->
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`;
elements.actionsBar.style.display = 'flex';
// Restore view toggle event listeners
document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView);
document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView);
// Update cached elements
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
setActionsBarMode('favorites');
// Show standard files containers
const filesGrid = document.getElementById('files-grid');
@@ -1392,39 +1401,7 @@ function switchToRecentFilesView() {
}
// Configure actions bar for recent view
elements.actionsBar.innerHTML = `
<div class="action-buttons">
<button class="btn btn-secondary" id="clear-recent-btn">
<i class="fas fa-broom" style="margin-right: 5px;"></i> <span data-i18n="actions.clear_recent">Clear recent</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`;
elements.actionsBar.style.display = 'flex';
// Add event listener for clear button
document.getElementById('clear-recent-btn').addEventListener('click', () => {
if (window.recent) {
window.recent.clearRecentFiles();
window.recent.displayRecentFiles();
window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
}
});
// Restore view toggle event listeners
document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView);
document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView);
// Update cached elements
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
setActionsBarMode('recent');
// Show standard files containers
const filesGrid = document.getElementById('files-grid');
+4 -10
View File
@@ -79,10 +79,6 @@ const sharedView = {
<option value="name" data-i18n="shared_sortByName">Sort by name</option>
<option value="expiration" data-i18n="shared_sortByExpiration">Sort by expiration</option>
</select>
<div class="shared-search-box">
<input type="text" id="shared-search-filter" data-i18n-placeholder="shared_search" placeholder="Search...">
<button id="shared-search-filter-btn" class="search-btn">🔍</button>
</div>
</div>
</div>
@@ -189,14 +185,10 @@ const sharedView = {
attachEventListeners() {
const filterType = document.getElementById('filter-type');
const sortBy = document.getElementById('sort-by');
const searchFilter = document.getElementById('shared-search-filter');
const searchBtn = document.getElementById('shared-search-filter-btn');
const emptyGoToFiles = document.getElementById('empty-go-to-files');
if (filterType) filterType.addEventListener('change', () => this.filterAndSortItems());
if (sortBy) sortBy.addEventListener('change', () => this.filterAndSortItems());
if (searchFilter) searchFilter.addEventListener('keyup', e => { if (e.key === 'Enter') this.filterAndSortItems(); });
if (searchBtn) searchBtn.addEventListener('click', () => this.filterAndSortItems());
if (emptyGoToFiles) emptyGoToFiles.addEventListener('click', () => window.switchToFilesView());
// Share dialog (sharedView-specific IDs)
@@ -238,11 +230,13 @@ const sharedView = {
filterAndSortItems() {
const filterType = document.getElementById('filter-type');
const sortBy = document.getElementById('sort-by');
const searchFilter = document.getElementById('shared-search-filter');
const type = filterType ? filterType.value : 'all';
const sort = sortBy ? sortBy.value : 'date';
const searchTerm = searchFilter ? searchFilter.value.toLowerCase() : '';
// Use the top-bar search if available, otherwise no filter
const topSearch = document.getElementById('shared-search');
const searchTerm = topSearch ? topSearch.value.toLowerCase() : '';
this.filteredItems = this.items.filter(item => {
if (type !== 'all' && item.item_type !== type) return false;
+49 -20
View File
@@ -5,6 +5,33 @@
// Context Menus Module
const contextMenus = {
_setFavoriteOptionLabel(optionId, isFavorite) {
const option = document.getElementById(optionId);
if (!option) return;
const label = option.querySelector('span');
if (!label) return;
label.textContent = window.i18n
? window.i18n.t(isFavorite ? 'actions.unfavorite' : 'actions.favorite')
: (isFavorite ? 'Remove from favorites' : 'Add to favorites');
},
syncFavoriteOptionLabels() {
if (!window.favorites) return;
const targetFile = window.app && window.app.contextMenuTargetFile;
const targetFolder = window.app && window.app.contextMenuTargetFolder;
if (targetFile) {
const isFav = window.favorites.isFavorite(targetFile.id, 'file');
this._setFavoriteOptionLabel('favorite-file-option', isFav);
}
if (targetFolder) {
const isFav = window.favorites.isFavorite(targetFolder.id, 'folder');
this._setFavoriteOptionLabel('favorite-folder-option', isFav);
}
},
/**
* Assign events to menu items and dialogs
*/
@@ -20,29 +47,30 @@ const contextMenus = {
window.ui.closeContextMenu();
});
document.getElementById('favorite-folder-option').addEventListener('click', () => {
document.getElementById('favorite-folder-option').addEventListener('click', async () => {
if (window.app.contextMenuTargetFolder) {
const folder = window.app.contextMenuTargetFolder;
// Check if folder is already in favorites to toggle
if (window.favorites && window.favorites.isFavorite(folder.id, 'folder')) {
// Remove from favorites
window.favorites.removeFromFavorites(folder.id, 'folder');
// Update menu item text
document.getElementById('favorite-folder-option').querySelector('span').textContent =
window.i18n ? window.i18n.t('actions.favorite') : 'Add to favorites';
const ok = await window.favorites.removeFromFavorites(folder.id, 'folder');
if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') {
window.ui.setFavoriteVisualState(folder.id, 'folder', false);
}
} else {
// Add to favorites
window.favorites.addToFavorites(
const ok = await window.favorites.addToFavorites(
folder.id,
folder.name,
'folder',
folder.parent_id
);
// Update menu item text
document.getElementById('favorite-folder-option').querySelector('span').textContent =
window.i18n ? window.i18n.t('actions.unfavorite') : 'Remove from favorites';
if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') {
window.ui.setFavoriteVisualState(folder.id, 'folder', true);
}
}
this.syncFavoriteOptionLabels();
}
window.ui.closeContextMenu();
});
@@ -120,29 +148,30 @@ const contextMenus = {
window.ui.closeFileContextMenu();
});
document.getElementById('favorite-file-option').addEventListener('click', () => {
document.getElementById('favorite-file-option').addEventListener('click', async () => {
if (window.app.contextMenuTargetFile) {
const file = window.app.contextMenuTargetFile;
// Check if file is already in favorites to toggle
if (window.favorites && window.favorites.isFavorite(file.id, 'file')) {
// Remove from favorites
window.favorites.removeFromFavorites(file.id, 'file');
// Update menu item text
document.getElementById('favorite-file-option').querySelector('span').textContent =
window.i18n ? window.i18n.t('actions.favorite') : 'Add to favorites';
const ok = await window.favorites.removeFromFavorites(file.id, 'file');
if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') {
window.ui.setFavoriteVisualState(file.id, 'file', false);
}
} else {
// Add to favorites
window.favorites.addToFavorites(
const ok = await window.favorites.addToFavorites(
file.id,
file.name,
'file',
file.folder_id
);
// Update menu item text
document.getElementById('favorite-file-option').querySelector('span').textContent =
window.i18n ? window.i18n.t('actions.unfavorite') : 'Remove from favorites';
if (ok && window.ui && typeof window.ui.setFavoriteVisualState === 'function') {
window.ui.setFavoriteVisualState(file.id, 'file', true);
}
}
this.syncFavoriteOptionLabels();
}
window.ui.closeFileContextMenu();
});
-10
View File
@@ -114,11 +114,6 @@ const favorites = {
);
}
// Refresh view to update star icons
if (window.app && window.app.currentSection === 'files' && typeof window.loadFiles === 'function') {
window.loadFiles();
}
return true;
} catch (error) {
console.error('Error adding to favorites:', error);
@@ -154,11 +149,6 @@ const favorites = {
);
}
// Refresh view to update star icons
if (window.app && window.app.currentSection === 'files' && typeof window.loadFiles === 'function') {
window.loadFiles();
}
return true;
} catch (error) {
console.error('Error removing from favorites:', error);
+432 -122
View File
@@ -26,9 +26,9 @@ const fileOps = {
_isUploading: false, // Guard against concurrent upload calls
/** Start a new upload batch in the notification bell */
_initUploadToast(totalFiles) {
_initUploadToast(totalFiles, folderName) {
this._currentBatchId = window.notifications
? window.notifications.addUploadBatch(totalFiles)
? window.notifications.addUploadBatch(totalFiles, folderName)
: null;
},
@@ -39,31 +39,115 @@ const fileOps = {
}
},
/**
* Some drag-and-drop sources can inject directory placeholders into
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
* when trying to send them as normal files.
*/
_canReadFileBlob(file) {
return new Promise((resolve) => {
try {
const reader = new FileReader();
reader.onload = () => resolve(true);
reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file.slice(0, 1));
} catch (_) {
resolve(false);
}
});
},
/**
* Upload a single file via XMLHttpRequest with progress events.
* Progress is reported to the notification bell via batchId + fileName.
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
*/
_uploadFileXHR(formData, batchId, fileName) {
_uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
const notif = window.notifications;
xhr.timeout = timeoutMs;
const hardDeadlineMs = Math.max(timeoutMs * 2, 180000);
let lastProgressPctSent = -1;
let isSettled = false;
let stallTimer = null;
let hardTimer = null;
const safeUpdateFile = (pct, status) => {
if (!notif || !batchId) return;
try {
notif.updateFile(batchId, fileName, pct, status);
} catch (e) {
console.warn('Notification update failed for upload row:', fileName, e);
}
};
const finalize = (result) => {
if (isSettled) return;
isSettled = true;
if (stallTimer) {
clearTimeout(stallTimer);
stallTimer = null;
}
if (hardTimer) {
clearTimeout(hardTimer);
hardTimer = null;
}
resolve(result);
};
const resetStallTimer = () => {
if (stallTimer) clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
try { xhr.abort(); } catch (_) {}
safeUpdateFile(0, 'error');
finalize({
ok: false,
isTimeout: true,
errorMsg: `Upload stalled for ${Math.round(timeoutMs / 1000)}s`
});
}, timeoutMs);
};
resetStallTimer();
hardTimer = setTimeout(() => {
try { xhr.abort(); } catch (_) {}
safeUpdateFile(0, 'error');
finalize({
ok: false,
isTimeout: true,
errorMsg: `Upload hard timeout after ${Math.round(hardDeadlineMs / 1000)}s`
});
}, hardDeadlineMs);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && notif && batchId) {
resetStallTimer();
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
notif.updateFile(batchId, fileName, pct, 'uploading');
// Throttle UI updates from very chatty progress events
if (pct === 100 || pct - lastProgressPctSent >= 10) {
lastProgressPctSent = pct;
safeUpdateFile(pct, 'uploading');
}
}
});
xhr.addEventListener('readystatechange', () => {
// Keep watchdog alive while request is actively moving through states
if (xhr.readyState > 1 && xhr.readyState < 4) {
resetStallTimer();
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
if (notif && batchId) notif.updateFile(batchId, fileName, 100, 'done');
safeUpdateFile(100, 'done');
let data = null;
try { data = JSON.parse(xhr.responseText); } catch (_) {}
resolve({ ok: true, data });
finalize({ ok: true, data });
} else {
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
safeUpdateFile(0, 'error');
// Parse error body for quota-exceeded or other messages
let errorMsg = null;
let isQuotaError = false;
@@ -72,13 +156,23 @@ const fileOps = {
errorMsg = errBody.error || null;
isQuotaError = errBody.error_type === 'QuotaExceeded' || xhr.status === 507;
} catch (_) {}
resolve({ ok: false, errorMsg, isQuotaError });
finalize({ ok: false, errorMsg, isQuotaError });
}
});
xhr.addEventListener('error', () => {
if (notif && batchId) notif.updateFile(batchId, fileName, 0, 'error');
resolve({ ok: false });
safeUpdateFile(0, 'error');
finalize({ ok: false });
});
xhr.addEventListener('abort', () => {
safeUpdateFile(0, 'error');
finalize({ ok: false, isTimeout: true, errorMsg: `Upload aborted/stalled: ${fileName}` });
});
xhr.addEventListener('timeout', () => {
safeUpdateFile(0, 'error');
finalize({ ok: false, isTimeout: true, errorMsg: `Timeout after ${Math.round(timeoutMs / 1000)}s` });
});
xhr.open('POST', '/api/files/upload');
@@ -88,10 +182,69 @@ const fileOps = {
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
xhr.send(formData);
try {
xhr.send(formData);
} catch (e) {
safeUpdateFile(0, 'error');
finalize({
ok: false,
errorMsg: `Client send() failed: ${e?.message || 'unknown error'}`
});
}
});
},
/**
* Upload a single file via fetch + AbortController.
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
*/
async _uploadFileFetch(formData, timeoutMs = 60000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch('/api/files/upload', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache, no-store, must-revalidate'
},
body: formData,
signal: controller.signal,
cache: 'no-store'
});
// Read body as text first (always consume the response fully)
let rawText = '';
try { rawText = await response.text(); } catch (_) {}
let body = null;
try { body = JSON.parse(rawText); } catch (_) {}
if (response.ok) {
return { ok: true, data: body };
}
const errorMsg = body && typeof body === 'object'
? (body.error || null)
: (rawText || null);
const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507;
return { ok: false, errorMsg, isQuotaError };
} catch (e) {
const isTimeout = e?.name === 'AbortError';
return {
ok: false,
isTimeout,
errorMsg: isTimeout
? `Timeout after ${Math.round(timeoutMs / 1000)}s`
: `Fetch upload failed: ${e?.message || 'network error'}`
};
} finally {
clearTimeout(timeoutId);
}
},
// ========================================================================
// Upload files (via button or drag-and-drop)
// ========================================================================
@@ -101,8 +254,8 @@ const fileOps = {
* @param {FileList} files - Files to upload
*/
async uploadFiles(files) {
const totalFiles = files.length;
if (totalFiles === 0) return;
const originalFiles = Array.from(files || []);
if (originalFiles.length === 0) return;
// Guard: prevent concurrent upload calls (e.g. double drop events)
if (this._isUploading) {
@@ -118,7 +271,39 @@ const fileOps = {
if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
if (progressBar) { progressBar.style.width = '0%'; }
// Show upload notification
// Filter out unreadable entries (typically dropped folders/placeholders)
const readableFiles = [];
const skippedEntries = [];
for (const f of originalFiles) {
// eslint-disable-next-line no-await-in-loop
const readable = await this._canReadFileBlob(f);
if (readable) readableFiles.push(f);
else skippedEntries.push(f.name || 'Unnamed entry');
}
const totalFiles = readableFiles.length;
if (skippedEntries.length > 0 && window.notifications) {
const locale = window.i18n?.getCurrentLocale?.() || 'en';
const title = locale.startsWith('es') ? 'Entradas omitidas' : 'Entries skipped';
const text = locale.startsWith('es')
? `Se omitieron ${skippedEntries.length} carpeta(s)/entrada(s) no legibles. Usa "Subir carpeta".`
: `${skippedEntries.length} unreadable folder/entry items were skipped. Use "Upload folder".`;
window.notifications.addNotification({
icon: 'fa-folder-open',
iconClass: 'upload',
title,
text
});
}
if (totalFiles === 0) {
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
this._isUploading = false;
return;
}
// Show upload notification (only for actual readable files)
this._initUploadToast(totalFiles);
const batchId = this._currentBatchId;
@@ -126,7 +311,8 @@ const fileOps = {
let successCount = 0;
for (let i = 0; i < totalFiles; i++) {
const file = files[i];
const file = readableFiles[i];
const formData = new FormData();
const targetFolderId = window.app.currentPath || window.app.userHomeFolderId;
@@ -147,7 +333,11 @@ const fileOps = {
}
// Notify bell of per-file completion
if (window.notifications && batchId) {
window.notifications.fileCompleted(batchId, result.ok);
try {
window.notifications.fileCompleted(batchId, result.ok);
} catch (e) {
console.warn('Batch progress update failed:', e);
}
}
if (result.ok) {
@@ -155,6 +345,14 @@ const fileOps = {
console.log(`Successfully uploaded ${file.name}`, result.data);
} else {
console.error(`Upload error for ${file.name}`);
if (result.isTimeout && window.notifications) {
window.notifications.addNotification({
icon: 'fa-clock',
iconClass: 'error',
title: file.name,
text: result.errorMsg || 'Upload timeout'
});
}
if (result.isQuotaError) {
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
if (window.notifications) {
@@ -198,135 +396,247 @@ const fileOps = {
* @param {FileList} files - Files from folder input (with webkitRelativePath)
*/
async uploadFolderFiles(files) {
if (!files || files.length === 0) return;
const entries = Array.from(files || []).map((file) => ({
file,
relativePath: file.webkitRelativePath || file.name
}));
await this.uploadFolderEntries(entries);
},
/**
* Upload folder-like entries preserving relative paths.
* @param {Array<{file: File, relativePath: string}>} entries
*/
async uploadFolderEntries(entries) {
const rawEntries = Array.isArray(entries) ? entries : [];
if (rawEntries.length === 0) return;
// Guard: prevent concurrent upload calls
if (this._isUploading) {
console.warn('Upload already in progress, ignoring duplicate call');
return;
}
this._isUploading = true;
const progressBar = document.querySelector('.progress-fill');
const uploadProgressDiv = document.querySelector('.upload-progress');
if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; }
if (progressBar) { progressBar.style.width = '0%'; }
const currentFolderId = window.app.currentPath || window.app.userHomeFolderId;
// Build folder structure from relative paths
const folderMap = new Map();
folderMap.set('', currentFolderId);
const folderPaths = new Set();
for (const file of files) {
const parts = file.webkitRelativePath.split('/');
for (let i = 1; i < parts.length; i++) {
const path = parts.slice(0, i).join('/');
folderPaths.add(path);
try {
// Filter unreadable entries
const validEntries = [];
for (const e of rawEntries) {
// eslint-disable-next-line no-await-in-loop
const readable = await this._canReadFileBlob(e.file);
if (readable) validEntries.push(e);
else console.warn(`Skipping unreadable folder entry: ${e.relativePath || e.file?.name}`);
}
}
const sortedPaths = [...folderPaths].sort((a, b) =>
a.split('/').length - b.split('/').length
);
// Create folders first (no progress toast for folder creation)
for (const folderPath of sortedPaths) {
const parts = folderPath.split('/');
const folderName = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
const parentId = folderMap.get(parentPath) || currentFolderId;
try {
const response = await fetch('/api/folders', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
},
body: JSON.stringify({
name: folderName,
parent_id: parentId
})
});
if (response.ok) {
const folder = await response.json();
folderMap.set(folderPath, folder.id);
console.log(`Created folder: ${folderPath} -> ${folder.id}`);
} else {
console.error(`Error creating folder ${folderPath}:`, await response.text());
window.ui.showNotification('Error', `Error creating folder: ${folderName}`);
const totalFiles = validEntries.length;
if (totalFiles === 0) {
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
return;
}
const currentFolderId = window.app.currentPath || window.app.userHomeFolderId;
// Build folder structure from relative paths
const folderMap = new Map();
folderMap.set('', currentFolderId);
const folderPaths = new Set();
for (const entry of validEntries) {
const rel = entry.relativePath || entry.file.name;
const parts = rel.split('/');
for (let i = 1; i < parts.length; i++) {
const path = parts.slice(0, i).join('/');
folderPaths.add(path);
}
} catch (error) {
console.error(`Network error creating folder ${folderPath}:`, error);
}
}
// Upload files with notification bell
const totalFiles = files.length;
this._initUploadToast(totalFiles);
const batchId = this._currentBatchId;
let uploadedCount = 0;
let successCount = 0;
for (let i = 0; i < totalFiles; i++) {
const file = files[i];
const parts = file.webkitRelativePath.split('/');
const parentPath = parts.slice(0, -1).join('/');
const targetFolderId = folderMap.get(parentPath) || currentFolderId;
const formData = new FormData();
formData.append('folder_id', targetFolderId);
// Use file.name as the explicit filename to prevent the browser
// from sending the full webkitRelativePath as the filename
formData.append('file', file, file.name);
const sortedPaths = [...folderPaths].sort((a, b) =>
a.split('/').length - b.split('/').length
);
const displayName = file.webkitRelativePath || file.name;
// Create folders first
for (const folderPath of sortedPaths) {
const parts = folderPath.split('/');
const folderName = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
const parentId = folderMap.get(parentPath) || currentFolderId;
const result = await this._uploadFileXHR(formData, batchId, displayName);
uploadedCount++;
if (progressBar) {
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
try {
const response = await fetch('/api/folders', {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
},
body: JSON.stringify({
name: folderName,
parent_id: parentId
})
});
if (response.ok) {
const folder = await response.json();
folderMap.set(folderPath, folder.id);
console.log(`Created folder: ${folderPath} -> ${folder.id}`);
} else {
console.error(`Error creating folder ${folderPath}:`, await response.text());
}
} catch (error) {
console.error(`Network error creating folder ${folderPath}:`, error);
}
}
if (window.notifications && batchId) {
window.notifications.fileCompleted(batchId, result.ok);
}
if (result.ok) {
successCount++;
console.log(`Uploaded: ${file.webkitRelativePath}`);
} else {
console.error(`Error uploading ${file.webkitRelativePath}`);
if (result.isQuotaError) {
const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded';
// Detect root folder(s) from entry paths
const rootFolderNames = [...new Set(validEntries.map((entry) => {
const rel = entry.relativePath || entry.file.name;
return rel.split('/')[0] || '';
}).filter(Boolean))];
const locale = window.i18n?.getCurrentLocale?.() || 'en';
const rootFolderLabel = rootFolderNames.length <= 1
? (rootFolderNames[0] || '')
: (locale.startsWith('es')
? `${rootFolderNames.length} carpetas`
: `${rootFolderNames.length} folders`);
// Upload files — pass folder name for folder-level progress display
this._initUploadToast(totalFiles, rootFolderLabel);
const batchId = this._currentBatchId;
let uploadedCount = 0;
let successCount = 0;
let quotaStop = false;
// ── Concurrent upload with limited parallelism ──────────
// FIFOs are pre-caught by the 0-byte arrayBuffer guard,
// so all files reaching fetch() are regular. Keep-alive
// reuses TCP connections across workers for speed.
const CONCURRENCY = 10;
const TIMEOUT_MS = 10000; // 10s for normal files
const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files
const uploadOneFile = async (idx) => {
if (quotaStop) return;
const entry = validEntries[idx];
const file = entry.file;
const rel = entry.relativePath || file.name;
let result = { ok: false, errorMsg: 'Unknown client error' };
try {
const parts = rel.split('/');
const parentPath = parts.slice(0, -1).join('/');
const targetFolderId = folderMap.get(parentPath) || currentFolderId;
// ── FIFO/pipe guard (0-byte files only) ──
// Named pipes (runit supervise/control) report size=0
// but block on open(). Pre-read only 0-byte files into
// memory; files with size>0 are always regular files and
// go straight to FormData (zero extra memory copy).
let uploadFile = file; // default: use original File
if (file.size === 0) {
try {
const buf = await Promise.race([
file.arrayBuffer(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error('read-timeout')), 2000))
]);
uploadFile = new Blob([buf], {
type: file.type || 'application/octet-stream'
});
} catch {
console.warn(`[SKIP] #${idx} ${rel} — cannot read 0-byte file (FIFO/pipe?), skipping`);
uploadedCount++;
successCount++;
if (window.notifications && batchId) {
try { window.notifications.fileCompleted(batchId, true); } catch (_) {}
}
return;
}
}
const formData = new FormData();
formData.append('folder_id', targetFolderId);
formData.append('file', uploadFile, file.name);
const thisTimeout = file.size === 0 ? TIMEOUT_MS_ZERO : TIMEOUT_MS;
console.log(`[UPLOAD START] #${idx} ${rel} (${file.size} bytes, timeout=${thisTimeout}ms)`);
result = await this._uploadFileFetch(formData, thisTimeout);
console.log(`[UPLOAD END] #${idx} ${rel} ok=${result.ok}${result.errorMsg ? ' err=' + result.errorMsg : ''}`);
} catch (e) {
result = {
ok: false,
errorMsg: `Client exception: ${e?.message || 'unknown'}`
};
console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e);
}
uploadedCount++;
if (window.notifications && batchId) {
try { window.notifications.fileCompleted(batchId, result.ok); } catch (_) {}
}
if (progressBar && uploadedCount % 10 === 0) {
progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%';
}
if (uploadedCount % 50 === 0 || uploadedCount === totalFiles) {
console.log(`Progress: ${uploadedCount}/${totalFiles} (${successCount} ok)`);
}
if (result.ok) {
successCount++;
} else if (result.isQuotaError) {
quotaStop = true;
if (window.notifications) {
window.notifications.addNotification({
icon: 'fa-exclamation-triangle',
iconClass: 'error',
title: file.name,
text: msg
text: result.errorMsg || 'Storage quota exceeded'
});
}
break;
}
};
// Pool-based concurrency: always keep CONCURRENCY tasks in flight
let nextIdx = 0;
const runNext = async () => {
while (nextIdx < totalFiles && !quotaStop) {
const idx = nextIdx++;
await uploadOneFile(idx);
}
};
const workers = [];
for (let w = 0; w < Math.min(CONCURRENCY, totalFiles); w++) {
workers.push(runNext());
}
}
// Finish
this._finishUploadToast(successCount, totalFiles);
await Promise.all(workers);
// Refresh storage usage display
if (typeof window.refreshUserData === 'function') {
try { await window.refreshUserData(); } catch (_) {}
}
this._finishUploadToast(successCount, totalFiles);
try {
await window.loadFiles({ forceRefresh: true });
} catch (reloadError) {
console.error('Error reloading files:', reloadError);
}
if (typeof window.refreshUserData === 'function') {
try { await window.refreshUserData(); } catch (_) {}
}
const dropzone = document.getElementById('dropzone');
if (dropzone) dropzone.style.display = 'none';
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
try {
await window.loadFiles({ forceRefresh: true });
} catch (reloadError) {
console.error('Error reloading files:', reloadError);
}
const dropzone = document.getElementById('dropzone');
if (dropzone) dropzone.style.display = 'none';
if (uploadProgressDiv) uploadProgressDiv.style.display = 'none';
} finally {
this._isUploading = false;
}
},
/**
+31 -2
View File
@@ -116,11 +116,40 @@ function t(key, params = {}) {
}
// Get the translation value
const value = getNestedValue(localeData, key);
let value = getNestedValue(localeData, key);
// Compatibility aliases for legacy share.* keys used in some views
if (!value) {
const aliasMap = {
'share.enablePassword': 'share.password',
'share.enableExpiration': 'share.expiration',
'share.notifyEmail': 'share.notifyEmailLabel',
'share.notifyMessage': 'share.notifyMessageLabel'
};
const aliasKey = aliasMap[key];
if (aliasKey) {
value = getNestedValue(localeData, aliasKey);
}
}
if (!value) {
// Try fallback to English
if (currentLocale !== 'en' && translations['en']) {
const fallbackValue = getNestedValue(translations['en'], key);
let fallbackValue = getNestedValue(translations['en'], key);
if (!fallbackValue) {
const aliasMap = {
'share.enablePassword': 'share.password',
'share.enableExpiration': 'share.expiration',
'share.notifyEmail': 'share.notifyEmailLabel',
'share.notifyMessage': 'share.notifyMessageLabel'
};
const aliasKey = aliasMap[key];
if (aliasKey) {
fallbackValue = getNestedValue(translations['en'], aliasKey);
}
}
if (fallbackValue) {
return interpolate(fallbackValue, params);
}
+10 -1
View File
@@ -85,6 +85,7 @@ const _ICONS = {
"sliders-h": [512, "M0 416c0 17.7 14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384c-17.7 0-32 14.3-32 32zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM320 256a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm32-80c-32.8 0-61 19.7-73.3 48L32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48zM192 128a32 32 0 1 1 0-64 32 32 0 1 1 0 64zm73.3-64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64z"],
"spinner": [512, "M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"],
"star": [576, "M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"],
"star-outline": [576, "M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L439.6 319.9l24.6 145.7c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7l-137-73.2L155.2 490.8c-8 4.3-17.8 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l24.6-145.7L39.6 218.2c-6.4-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L274.3 13.5C278.3 5.2 286.7 0 295.9 0h-8zm0 79L235.4 187.2c-3.5 7.1-10.2 12.1-18.1 13.3L99 218.9l85.8 85.1c5.5 5.5 8.1 13.3 6.8 21L171.3 444.7l111.5-59.5c7-3.7 15.3-3.7 22.3 0l111.5 59.5-20.3-119.7c-1.3-7.7 1.2-15.5 6.8-21l85.8-85.1-118.3-17.4c-7.8-1.2-14.6-6.1-18.1-13.3L287.9 79z"],
"terminal": [576, "M9.4 86.6C-3.1 74.1-3.1 53.9 9.4 41.4s32.8-12.5 45.3 0l192 192c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L178.7 256 9.4 86.6zM256 416l288 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-288 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"],
"th": [512, "M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm88 64l0 64-88 0 0-64 88 0zm56 0l88 0 0 64-88 0 0-64zm240 0l0 64-88 0 0-64 88 0zM64 224l88 0 0 64-88 0 0-64zm232 0l0 64-88 0 0-64 88 0zm64 0l88 0 0 64-88 0 0-64zM152 352l0 64-88 0 0-64 88 0zm56 0l88 0 0 64-88 0 0-64zm240 0l0 64-88 0 0-64 88 0z"],
"times": [384, "M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"],
@@ -165,15 +166,23 @@ function replaceIconsInElement(container) {
let isSpin = false;
const extraClasses = [];
let isRegular = false; // far = outline variant
for (const cls of classes) {
if (cls === "fa-spin") { isSpin = true; continue; }
if (cls === "far") { isRegular = true; continue; }
if (cls.startsWith("fa-") && cls !== "fa") {
iconName = cls.substring(3); // strip "fa-"
} else if (cls !== "fas" && cls !== "fab" && cls !== "far" && cls !== "fa") {
} else if (cls !== "fas" && cls !== "fab" && cls !== "fa") {
extraClasses.push(cls);
}
}
// Use outline variant if available and element uses "far"
if (isRegular && _ICONS[iconName + "-outline"]) {
iconName = iconName + "-outline";
}
if (!iconName || !_ICONS[iconName]) continue;
const [w, d] = _ICONS[iconName];
+57 -38
View File
@@ -130,9 +130,11 @@ const notifications = (() => {
/**
* Start tracking a new upload batch. Returns a batchId string.
* This also auto-opens the panel so users see progress.
* Always uses compact folder-level display: one progress bar + counter.
* @param {number} totalFiles
* @param {string} [folderName] root folder name (for folder uploads)
*/
function addUploadBatch(totalFiles) {
function addUploadBatch(totalFiles, folderName) {
const batchId = 'batch-' + (++_batchSeq);
const body = $('notif-panel-body');
if (!body) return batchId;
@@ -141,17 +143,22 @@ const notifications = (() => {
item.className = 'notif-item';
item.id = batchId;
const uploadingText = (window.i18n && window.i18n.t) ? window.i18n.t('upload.uploading') : 'Uploading…';
const locale = window.i18n?.getCurrentLocale?.() || 'en';
const uploadingText = folderName
? (locale.startsWith('es') ? `📁 Subiendo ${_esc(folderName)}…` : `📁 Uploading ${_esc(folderName)}…`)
: (locale.startsWith('es') ? 'Subiendo…' : 'Uploading…');
const filesLabel = locale.startsWith('es') ? 'archivos' : 'files';
item.innerHTML = `
<div class="notif-item-icon upload"><i class="fas fa-cloud-upload-alt"></i></div>
<div class="notif-item-body">
<div class="notif-item-title">${_esc(uploadingText)}</div>
<div class="notif-upload-files" id="${batchId}-files"></div>
<div class="notif-item-title">${uploadingText}</div>
<div class="notif-upload-current" id="${batchId}-current" style="font-size:11px;color:#64748b;margin:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></div>
<div class="notif-upload-progress">
<div class="notif-upload-bar"><div class="notif-upload-fill" id="${batchId}-fill"></div></div>
<div class="notif-upload-detail">
<span class="notif-upload-pct" id="${batchId}-pct">0%</span>
<span class="notif-upload-stats" id="${batchId}-stats">0 / ${totalFiles}</span>
<span class="notif-upload-stats" id="${batchId}-stats">0 / ${totalFiles} ${filesLabel}</span>
</div>
</div>
<div class="notif-item-time">${_timeAgo()}</div>
@@ -161,7 +168,15 @@ const notifications = (() => {
// Insert at top
body.insertBefore(item, body.firstChild);
_batches[batchId] = { el: item, files: {}, totalFiles, completed: 0, successCount: 0 };
_batches[batchId] = {
el: item,
totalFiles,
completed: 0,
successCount: 0,
errorCount: 0,
lastLabelUpdateTs: 0,
lastLabelFile: ''
};
_showEmptyIfNeeded();
// Auto open
@@ -176,7 +191,8 @@ const notifications = (() => {
}
/**
* Add / update a single file row inside a batch.
* Update the current-file label inside a batch.
* This does NOT create any DOM rows — just a single text update.
* @param {string} batchId
* @param {string} fileName
* @param {number} pct 0-100
@@ -186,39 +202,29 @@ const notifications = (() => {
const batch = _batches[batchId];
if (!batch) return;
const filesEl = $(batchId + '-files');
if (!filesEl) return;
if (status === 'error') batch.errorCount = (batch.errorCount || 0) + 1;
let row = batch.files[fileName];
if (!row) {
row = document.createElement('div');
row.className = 'notif-upload-file-row';
row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:2px 0;font-size:12px;';
row.innerHTML = `
<span class="notif-file-icon" style="width:16px;text-align:center;color:#999;flex-shrink:0;"><i class="fas fa-spinner fa-spin"></i></span>
<span class="notif-file-name" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#64748b;" title="${_esc(fileName)}">${_esc(fileName)}</span>
<span class="notif-file-pct" style="width:34px;text-align:right;color:#94a3b8;flex-shrink:0;">0%</span>
`;
filesEl.appendChild(row);
batch.files[fileName] = row;
}
// Only update the current-file label (single DOM element)
const curEl = $(batchId + '-current');
if (curEl && status === 'uploading') {
const now = Date.now();
const fileChanged = batch.lastLabelFile !== fileName;
const shouldUpdate = fileChanged || now - (batch.lastLabelUpdateTs || 0) >= 300 || pct >= 100;
if (!shouldUpdate) return;
const iconEl = row.querySelector('.notif-file-icon');
const pctEl = row.querySelector('.notif-file-pct');
pctEl.textContent = pct + '%';
if (status === 'done') {
iconEl.innerHTML = '<i class="fas fa-check-circle" style="color:#34c759"></i>';
pctEl.textContent = '100%';
} else if (status === 'error') {
iconEl.innerHTML = '<i class="fas fa-exclamation-circle" style="color:#ff3b30"></i>';
pctEl.textContent = 'ERR';
// Show just the file name being uploaded (truncate long paths)
const shortName = fileName.length > 50
? '…' + fileName.slice(-49)
: fileName;
curEl.textContent = shortName;
batch.lastLabelFile = fileName;
batch.lastLabelUpdateTs = now;
}
}
/**
* Mark a file as completed within a batch (updates overall bar).
* DOM updates are throttled to every 5 files to avoid reflow starvation.
*/
function fileCompleted(batchId, success) {
const batch = _batches[batchId];
@@ -226,14 +232,21 @@ const notifications = (() => {
batch.completed++;
if (success) batch.successCount++;
// Throttle DOM updates: every 5 files, or on the very last file
const isLast = batch.completed >= batch.totalFiles;
if (!isLast && batch.completed % 5 !== 0) return;
const pctVal = Math.round((batch.completed / batch.totalFiles) * 100);
const fillEl = $(batchId + '-fill');
const pctEl = $(batchId + '-pct');
const statsEl = $(batchId + '-stats');
const locale = window.i18n?.getCurrentLocale?.() || 'en';
const filesLabel = locale.startsWith('es') ? 'archivos' : 'files';
if (fillEl) fillEl.style.width = pctVal + '%';
if (pctEl) pctEl.textContent = pctVal + '%';
if (statsEl) statsEl.textContent = `${batch.completed} / ${batch.totalFiles}`;
if (statsEl) statsEl.textContent = `${batch.completed} / ${batch.totalFiles} ${filesLabel}`;
}
/**
@@ -252,9 +265,15 @@ const notifications = (() => {
const titleEl = batch.el.querySelector('.notif-item-title');
const iconEl = batch.el.querySelector('.notif-item-icon');
const completeText = (window.i18n && window.i18n.t)
? window.i18n.t('upload.complete', { count: successCount, total: totalFiles })
: `${successCount} / ${totalFiles} uploaded`;
// Clear the current-file label
const curEl = $(batchId + '-current');
if (curEl) curEl.textContent = '';
const locale = window.i18n?.getCurrentLocale?.() || 'en';
const filesLabel = locale.startsWith('es') ? 'archivos' : 'files';
const completeText = locale.startsWith('es')
? `✅ ${successCount} / ${totalFiles} ${filesLabel} subidos`
: `✅ ${successCount} / ${totalFiles} ${filesLabel} uploaded`;
if (titleEl) titleEl.textContent = completeText;
if (iconEl) {
+8 -1
View File
@@ -82,7 +82,14 @@ document.addEventListener('DOMContentLoaded', async () => {
if (filterType) filterType.addEventListener('change', filterAndSortItems);
if (sortBy) sortBy.addEventListener('change', filterAndSortItems);
if (sharedSearchBtn) sharedSearchBtn.addEventListener('click', filterAndSortItems);
if (sharedSearch) sharedSearch.addEventListener('keyup', e => { if (e.key === 'Enter') filterAndSortItems(); });
if (sharedSearch) {
let searchDebounce;
sharedSearch.addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(filterAndSortItems, 250);
});
sharedSearch.addEventListener('keyup', e => { if (e.key === 'Enter') { clearTimeout(searchDebounce); filterAndSortItems(); } });
}
if (goToFilesBtn) goToFilesBtn.addEventListener('click', () => window.location.href = '/');
if (shareDialogCloseBtn) shareDialogCloseBtn.addEventListener('click', closeShareDialog);
+375 -53
View File
@@ -275,6 +275,57 @@ const ui = {
setupDragAndDrop() {
const dropzone = document.getElementById('dropzone');
const collectDroppedEntries = async (dataTransfer) => {
const items = Array.from(dataTransfer?.items || []);
const rootEntries = items
.map(it => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null))
.filter(Boolean);
if (rootEntries.length === 0) return null;
const out = [];
const walkEntry = async (entry, prefix = '') => {
if (!entry) return;
if (entry.isFile) {
await new Promise((resolve) => {
entry.file(
(file) => {
out.push({ file, relativePath: `${prefix}${file.name}` });
resolve();
},
() => resolve()
);
});
return;
}
if (entry.isDirectory) {
const dirPrefix = `${prefix}${entry.name}/`;
const reader = entry.createReader();
while (true) {
const children = await new Promise((resolve) => {
reader.readEntries(resolve, () => resolve([]));
});
if (!children || children.length === 0) break;
for (const child of children) {
// eslint-disable-next-line no-await-in-loop
await walkEntry(child, dirPrefix);
}
}
}
};
for (const root of rootEntries) {
// eslint-disable-next-line no-await-in-loop
await walkEntry(root, '');
}
return out;
};
// Dropzone events
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
@@ -285,12 +336,27 @@ const ui = {
dropzone.classList.remove('active');
});
dropzone.addEventListener('drop', (e) => {
dropzone.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation(); // Prevent bubbling to document's drop handler (avoids double upload)
e._oxiHandled = true; // Mark as handled for document-level fallback
dropzone.classList.remove('active');
if (e.dataTransfer.files.length > 0) {
// First try directory-aware extraction (Finder folder drag & drop)
const droppedEntries = await collectDroppedEntries(e.dataTransfer);
if (droppedEntries && droppedEntries.length > 0) {
const hasFolderStructure = droppedEntries.some(x => x.relativePath && x.relativePath.includes('/'));
if (hasFolderStructure) {
fileOps.uploadFolderEntries(droppedEntries);
} else {
fileOps.uploadFiles(droppedEntries.map(x => x.file));
}
setTimeout(() => {
dropzone.style.display = 'none';
}, 500);
return;
}
// Detect folder drops: files from folder drops have webkitRelativePath set
const hasRelativePaths = Array.from(e.dataTransfer.files).some(
f => f.webkitRelativePath && f.webkitRelativePath.includes('/')
@@ -327,7 +393,7 @@ const ui = {
}
});
document.addEventListener('drop', (e) => {
document.addEventListener('drop', async (e) => {
e.preventDefault();
dropzone.classList.remove('active');
@@ -335,6 +401,21 @@ const ui = {
if (e._oxiHandled) return;
if (e.dataTransfer.files.length > 0) {
// First try directory-aware extraction (Finder folder drag & drop)
const droppedEntries = await collectDroppedEntries(e.dataTransfer);
if (droppedEntries && droppedEntries.length > 0) {
const hasFolderStructure = droppedEntries.some(x => x.relativePath && x.relativePath.includes('/'));
if (hasFolderStructure) {
fileOps.uploadFolderEntries(droppedEntries);
} else {
fileOps.uploadFiles(droppedEntries.map(x => x.file));
}
setTimeout(() => {
dropzone.style.display = 'none';
}, 500);
return;
}
// Detect folder drops: files from folder drops have webkitRelativePath set
const hasRelativePaths = Array.from(e.dataTransfer.files).some(
f => f.webkitRelativePath && f.webkitRelativePath.includes('/')
@@ -361,6 +442,8 @@ const ui = {
const gridViewBtn = document.getElementById('grid-view-btn');
const listViewBtn = document.getElementById('list-view-btn');
this._hydrateViewIfNeeded('grid');
filesGrid.style.display = 'grid';
filesListView.style.display = 'none';
gridViewBtn.classList.add('active');
@@ -378,6 +461,8 @@ const ui = {
const gridViewBtn = document.getElementById('grid-view-btn');
const listViewBtn = document.getElementById('list-view-btn');
this._hydrateViewIfNeeded('list');
filesGrid.style.display = 'none';
filesListView.style.display = 'flex';
gridViewBtn.classList.remove('active');
@@ -502,12 +587,80 @@ const ui = {
return map[ext] || 'fas fa-file';
},
/**
* Get CSS special class for icon styling based on filename extension.
* Used as fallback when the backend DTO doesn't include icon_special_class.
*/
getIconSpecialClass(fileName) {
if (!fileName) return '';
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf:'pdf-icon',
doc:'doc-icon', docx:'doc-icon', odt:'doc-icon', rtf:'doc-icon',
xls:'spreadsheet-icon', xlsx:'spreadsheet-icon', ods:'spreadsheet-icon', csv:'spreadsheet-icon',
ppt:'presentation-icon', pptx:'presentation-icon', odp:'presentation-icon', key:'presentation-icon',
jpg:'image-icon', jpeg:'image-icon', png:'image-icon', gif:'image-icon',
svg:'image-icon', webp:'image-icon', bmp:'image-icon', ico:'image-icon',
heic:'image-icon', heif:'image-icon', avif:'image-icon', tiff:'image-icon',
mp4:'video-icon', avi:'video-icon', mkv:'video-icon', mov:'video-icon',
wmv:'video-icon', flv:'video-icon', webm:'video-icon', m4v:'video-icon',
mp3:'audio-icon', wav:'audio-icon', ogg:'audio-icon', flac:'audio-icon',
aac:'audio-icon', wma:'audio-icon', m4a:'audio-icon', opus:'audio-icon',
zip:'archive-icon', rar:'archive-icon', '7z':'archive-icon',
tar:'archive-icon', gz:'archive-icon', bz2:'archive-icon', xz:'archive-icon',
exe:'installer-icon', msi:'installer-icon', dmg:'installer-icon',
deb:'installer-icon', rpm:'installer-icon', appimage:'installer-icon',
py:'code-icon py-icon', rs:'code-icon rust-icon', go:'code-icon go-icon',
js:'code-icon js-icon', jsx:'code-icon js-icon', mjs:'code-icon js-icon',
ts:'code-icon ts-icon', tsx:'code-icon ts-icon',
java:'code-icon java-icon', c:'code-icon c-icon', cpp:'code-icon c-icon',
cs:'code-icon cs-icon', rb:'code-icon ruby-icon', php:'code-icon php-icon',
swift:'code-icon swift-icon',
html:'code-icon html-icon', htm:'code-icon html-icon',
css:'code-icon css-icon', scss:'code-icon css-icon',
json:'code-icon json-icon', xml:'code-icon html-icon',
yaml:'code-icon config-icon', yml:'code-icon config-icon',
toml:'code-icon config-icon', ini:'code-icon config-icon',
sql:'code-icon sql-icon', vue:'code-icon js-icon', svelte:'code-icon js-icon',
sh:'script-icon', bash:'script-icon', zsh:'script-icon', bat:'script-icon',
md:'code-icon md-icon', txt:'doc-icon',
};
return map[ext] || '';
},
/**
* Show notification
* @param {string} title - Notification title
* @param {string} message - Notification message
*/
showNotification(title, message) {
// Prefer the bell notification center
if (window.notifications && typeof window.notifications.addNotification === 'function') {
const t = String(title || '').toLowerCase();
let icon = 'fa-info-circle';
let iconClass = 'upload';
if (t.includes('error') || t.includes('failed') || t.includes('fail')) {
icon = 'fa-exclamation-circle';
iconClass = 'error';
} else if (t.includes('favorite') || t.includes('favorit') || t.includes('fav')) {
icon = 'fa-star';
iconClass = 'success';
} else if (t.includes('delete') || t.includes('removed') || t.includes('trash') || t.includes('rename') || t.includes('complete')) {
icon = 'fa-check-circle';
iconClass = 'success';
}
window.notifications.addNotification({
icon,
iconClass,
title: title || '',
text: message || ''
});
return;
}
// Legacy floating toast fallback (pages without bell)
let notification = document.querySelector('.notification');
if (!notification) {
notification = document.createElement('div');
@@ -560,9 +713,92 @@ const ui = {
/** @type {Map<string, Object>} item data keyed by id */
_items: new Map(),
/** @type {Array<Object>} last rendered folder dataset */
_lastFolders: [],
/** @type {Array<Object>} last rendered file dataset */
_lastFiles: [],
/** @type {boolean} */
_delegationReady: false,
_getActiveView() {
if (window.app && window.app.currentView === 'list') return 'list';
if (window.app && window.app.currentView === 'grid') return 'grid';
const stored = localStorage.getItem('oxicloud-view');
return stored === 'list' ? 'list' : 'grid';
},
_renderFoldersToView(folders, view) {
if (!Array.isArray(folders) || folders.length === 0) return;
const target = view === 'list'
? document.getElementById('files-list-view')
: document.getElementById('files-grid');
if (!target) return;
const frag = document.createDocumentFragment();
for (const folder of folders) {
frag.appendChild(view === 'list'
? this._createFolderItem(folder)
: this._createFolderCard(folder));
}
target.appendChild(frag);
},
_renderFilesToView(files, view) {
if (!Array.isArray(files) || files.length === 0) return;
const target = view === 'list'
? document.getElementById('files-list-view')
: document.getElementById('files-grid');
if (!target) return;
const frag = document.createDocumentFragment();
for (const file of files) {
frag.appendChild(view === 'list'
? this._createFileItem(file)
: this._createFileCard(file));
}
target.appendChild(frag);
},
_upsertById(arr, item) {
if (!Array.isArray(arr) || !item || !item.id) return;
const idx = arr.findIndex(x => x && x.id === item.id);
if (idx >= 0) {
arr[idx] = item;
} else {
arr.push(item);
}
},
_hydrateViewIfNeeded(view) {
// Only hydrate if there is at least one rendered item in the opposite/current DOM.
// This prevents stale cache hydration in empty-state screens.
const hasAnyRenderedItem = !!document.querySelector('#files-grid .file-card, #files-list-view .file-item');
if (!hasAnyRenderedItem) return;
if (view === 'grid') {
const grid = document.getElementById('files-grid');
if (!grid) return;
if (grid.children.length > 0) return;
this._renderFoldersToView(this._lastFolders, 'grid');
this._renderFilesToView(this._lastFiles, 'grid');
return;
}
if (view === 'list') {
const list = document.getElementById('files-list-view');
if (!list) return;
// list view keeps a static header row as first child
if (list.querySelector('.file-item')) return;
this._renderFoldersToView(this._lastFolders, 'list');
this._renderFilesToView(this._lastFiles, 'list');
}
},
/**
* Attach a fixed set of delegated event listeners to the two
* container elements (files-grid, files-list-view).
@@ -622,7 +858,7 @@ const ui = {
}
};
// ── GRID: click (select) ──────────────────────────────────
// ── GRID: click (open / navigate; select only via checkbox) ──
grid.addEventListener('click', (e) => {
const card = e.target.closest('.file-card');
if (!card) return;
@@ -645,26 +881,10 @@ const ui = {
return;
}
// In favorites/recent view, single-click navigates/opens (like list)
if (window.app.isFavoritesView || window.app.isRecentView) {
const info = itemInfo(card);
if (info) {
if (info.type === 'folder') navigateFolder(card);
else openFile(info.data);
}
return;
}
toggleCardSelection(card, e);
});
// ── GRID: dblclick (navigate / open) ──────────────────────
grid.addEventListener('dblclick', (e) => {
const card = e.target.closest('.file-card');
if (!card) return;
if (e.target.closest('.file-card-more') ||
e.target.closest('.file-card-checkbox')) return;
// Favorite star – handled by direct onclick on the button
if (e.target.closest('.favorite-star')) return;
// Single-click opens/navigates (selection is only via checkbox)
const info = itemInfo(card);
if (!info) return;
@@ -675,6 +895,13 @@ const ui = {
}
});
// ── GRID: dblclick (navigate / open) ──────────────────────
grid.addEventListener('dblclick', (e) => {
// Single-click already handles open/navigate.
// Prevent duplicate actions on double-click.
e.preventDefault();
});
// ── LIST: click (navigate / open) ─────────────────────────
list.addEventListener('click', (e) => {
if (e.target.closest('.list-header')) return;
@@ -712,6 +939,9 @@ const ui = {
const menuId = info.type === 'folder'
? 'folder-context-menu' : 'file-context-menu';
const menu = document.getElementById(menuId);
if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') {
window.contextMenus.syncFavoriteOptionLabels();
}
menu.style.left = `${e.pageX}px`;
menu.style.top = `${e.pageY}px`;
menu.style.display = 'block';
@@ -794,7 +1024,99 @@ const ui = {
},
/* ================================================================
* Pure element-creation helpers (no addEventListener)
* Favorite star helper – attaches a direct click handler to a
* star <button> so the event never bubbles to the card.
* ================================================================ */
_bindStarClick(el) {
const star = el.querySelector('.favorite-star');
if (!star) return;
star.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
if (!window.favorites) return;
const itemId = star.dataset.itemId;
const itemType = star.dataset.itemType;
const itemName = star.dataset.itemName;
const isActive = star.classList.contains('active');
if (isActive) {
this.setFavoriteVisualState(itemId, itemType, false);
window.favorites.removeFromFavorites(itemId, itemType);
} else {
this.setFavoriteVisualState(itemId, itemType, true);
window.favorites.addToFavorites(itemId, itemName, itemType);
}
// Keep context-menu label in sync if available
if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') {
window.contextMenus.syncFavoriteOptionLabels();
}
});
},
/**
* Sync favorite visuals for a file/folder across grid and list views.
*/
setFavoriteVisualState(itemId, itemType, isFavorite) {
const cardSelector = itemType === 'folder'
? `.file-card[data-folder-id="${itemId}"]`
: `.file-card[data-file-id="${itemId}"]`;
const listSelector = itemType === 'folder'
? `.file-item[data-folder-id="${itemId}"]`
: `.file-item[data-file-id="${itemId}"]`;
const card = document.querySelector(cardSelector);
const starBtn = card ? card.querySelector('.favorite-star') : null;
if (starBtn) {
starBtn.classList.toggle('active', !!isFavorite);
// SVG icon path (after icons.js replacement)
const svg = starBtn.querySelector('svg');
const filledPath = window.OxiIcons && window.OxiIcons['star'];
const outlinePath = window.OxiIcons && window.OxiIcons['star-outline'];
const targetPath = isFavorite ? filledPath : outlinePath;
if (svg && targetPath) {
const p = svg.querySelector('path');
if (p) p.setAttribute('d', targetPath[1]);
svg.setAttribute('viewBox', `0 0 ${targetPath[0]} 512`);
}
// Fallback <i> icon (before icons.js replacement)
const i = starBtn.querySelector('i');
if (i) {
i.classList.remove('fas', 'far');
i.classList.add(isFavorite ? 'fas' : 'far');
}
}
const listItem = document.querySelector(listSelector);
if (listItem) {
const nameCell = listItem.querySelector('.name-cell');
if (nameCell) {
let inlineStar = nameCell.querySelector('.favorite-star-inline');
if (isFavorite && !inlineStar) {
inlineStar = document.createElement('i');
inlineStar.className = 'fas fa-star favorite-star-inline';
nameCell.appendChild(inlineStar);
if (window.OxiIcons && typeof window.OxiIcons.replaceIconsInElement === 'function') {
window.OxiIcons.replaceIconsInElement(nameCell);
}
} else if (!isFavorite && inlineStar) {
inlineStar.remove();
}
}
}
},
/* ================================================================
* Element-creation helpers
* ================================================================ */
/** Create a grid card for a folder */
@@ -811,7 +1133,9 @@ const ui = {
el.innerHTML = `
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
${isFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
<button class="favorite-star${isFav ? ' active' : ''}" data-item-id="${folder.id}" data-item-type="folder" data-item-name="${escapeHtml(folder.name)}">
<i class="${isFav ? 'fas' : 'far'} fa-star"></i>
</button>
<div class="file-icon folder-icon">
<i class="fas fa-folder"></i>
</div>
@@ -822,6 +1146,7 @@ const ui = {
if (window.app.currentPath !== "") {
el.setAttribute('draggable', 'true');
}
this._bindStarClick(el);
return el;
},
@@ -859,8 +1184,8 @@ const ui = {
/** Create a grid card for a file */
_createFileCard(file) {
const iconClass = file.icon_class || 'fas fa-file';
const iconSpecialClass = file.icon_special_class || '';
const iconClass = file.icon_class || this.getIconClass(file.name);
const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name);
const isFileFav = window.favorites &&
window.favorites.isFavorite(file.id, 'file');
const formattedDate = window.formatDateTime(file.modified_at);
@@ -875,20 +1200,23 @@ const ui = {
el.innerHTML = `
<div class="file-card-checkbox"><i class="fas fa-check"></i></div>
<button class="file-card-more"><i class="fas fa-ellipsis-v"></i></button>
${isFileFav ? '<div class="favorite-star active"><i class="fas fa-star"></i></div>' : ''}
<button class="favorite-star${isFileFav ? ' active' : ''}" data-item-id="${file.id}" data-item-type="file" data-item-name="${escapeHtml(file.name)}">
<i class="${isFileFav ? 'fas' : 'far'} fa-star"></i>
</button>
<div class="file-icon ${iconSpecialClass}">
<i class="${iconClass}"></i>
</div>
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-info">Modified ${formattedDate.split(' ')[0]}</div>
`;
this._bindStarClick(el);
return el;
},
/** Create a list row for a file */
_createFileItem(file) {
const iconClass = file.icon_class || 'fas fa-file';
const iconSpecialClass = file.icon_special_class || '';
const iconClass = file.icon_class || this.getIconClass(file.name);
const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name);
const cat = file.category || '';
const typeLabel = cat
? (window.i18n
@@ -935,17 +1263,14 @@ const ui = {
*/
renderFolders(folders) {
if (!this._delegationReady) this.initDelegation();
const gridFrag = document.createDocumentFragment();
const listFrag = document.createDocumentFragment();
const safeFolders = Array.isArray(folders) ? folders : [];
this._lastFolders = safeFolders.slice();
for (const folder of folders) {
for (const folder of safeFolders) {
this._items.set(folder.id, folder);
gridFrag.appendChild(this._createFolderCard(folder));
listFrag.appendChild(this._createFolderItem(folder));
}
document.getElementById('files-grid').appendChild(gridFrag);
document.getElementById('files-list-view').appendChild(listFrag);
this._renderFoldersToView(safeFolders, this._getActiveView());
},
/**
@@ -954,17 +1279,14 @@ const ui = {
*/
renderFiles(files) {
if (!this._delegationReady) this.initDelegation();
const gridFrag = document.createDocumentFragment();
const listFrag = document.createDocumentFragment();
const safeFiles = Array.isArray(files) ? files : [];
this._lastFiles = safeFiles.slice();
for (const file of files) {
for (const file of safeFiles) {
this._items.set(file.id, file);
gridFrag.appendChild(this._createFileCard(file));
listFrag.appendChild(this._createFileItem(file));
}
document.getElementById('files-grid').appendChild(gridFrag);
document.getElementById('files-list-view').appendChild(listFrag);
this._renderFilesToView(safeFiles, this._getActiveView());
},
/* ================================================================
@@ -972,7 +1294,7 @@ const ui = {
* ================================================================ */
/**
* Add a single folder to both views.
* Add a single folder to the active view.
* @param {Object} folder - Folder object
*/
addFolderToView(folder) {
@@ -986,14 +1308,12 @@ const ui = {
}
this._items.set(folder.id, folder);
document.getElementById('files-grid')
.appendChild(this._createFolderCard(folder));
document.getElementById('files-list-view')
.appendChild(this._createFolderItem(folder));
this._upsertById(this._lastFolders, folder);
this._renderFoldersToView([folder], this._getActiveView());
},
/**
* Add a single file to both views.
* Add a single file to the active view.
* @param {Object} file - File object
*/
addFileToView(file) {
@@ -1007,10 +1327,8 @@ const ui = {
}
this._items.set(file.id, file);
document.getElementById('files-grid')
.appendChild(this._createFileCard(file));
document.getElementById('files-list-view')
.appendChild(this._createFileItem(file));
this._upsertById(this._lastFiles, file);
this._renderFilesToView([file], this._getActiveView());
}
};
@@ -1051,6 +1369,10 @@ function showContextMenuAtElement(triggerElement, menuId) {
top = rect.top - 4 + window.scrollY; // flip above if no room
}
if (window.contextMenus && typeof window.contextMenus.syncFavoriteOptionLabels === 'function') {
window.contextMenus.syncFavoriteOptionLabels();
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.display = 'block';