perf: optimize UI rendering and event lifecycle
This commit is contained in:
+192
-215
@@ -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');
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user