/** * OxiCloud - UI Module * This file handles UI-related functions, view toggling, and interface interactions */ // @ts-check import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { OxiIcons } from '../core/icons.js'; import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; import { multiSelect } from '../features/files/multiSelect.js'; import { wopiEditor } from '../features/files/wopiEditor.js'; import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { fileSharing } from '../features/sharing/fileSharing.js'; import { thumbnail } from '../features/thumbnail.js'; import { grants } from '../model/grants.js'; import { loadFiles } from './filesView.js'; import { updateHistory } from './main.js'; import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js'; import { app } from './state.js'; import { uiFileTypes } from './uiFileTypes.js'; import { uiNotifications } from './uiNotifications.js'; /** * @import {FileItem, FolderItem} from '../core/types.js' * @import {BatchResult} from '../features/files/fileOperations.js' */ // UI Module const ui = { /** @type {HTMLDivElement | null} */ dragPreview: null, /** @type {HTMLDivElement | null} */ draggedItems: null, /** * Initialize context menus and dialogs */ initializeContextMenus() { // Folder context menu if (!document.getElementById('folder-context-menu')) { const folderMenu = document.createElement('div'); folderMenu.classList.add('context-menu', 'hidden'); folderMenu.id = 'folder-context-menu'; folderMenu.innerHTML = `
Download
Add to favorites
Share
Rename
Move to...
Delete
`; document.body.appendChild(folderMenu); i18n.translateElement(folderMenu); } // File context menu if (!document.getElementById('file-context-menu')) { const fileMenu = document.createElement('div'); fileMenu.classList.add('context-menu', 'hidden'); fileMenu.id = 'file-context-menu'; fileMenu.innerHTML = `
View
Download
Add to favorites
Share
Rename
Move to...
Delete
`; document.body.appendChild(fileMenu); i18n.translateElement(fileMenu); } // Move dialog — modern with navigation if (!document.getElementById('move-file-dialog')) { const moveDialog = document.createElement('div'); moveDialog.classList.add('rename-dialog', 'hidden'); moveDialog.id = 'move-file-dialog'; moveDialog.innerHTML = `
Move

Select destination folder:

`; document.body.appendChild(moveDialog); } // Share dialog if (!document.getElementById('share-dialog')) { const shareDialog = document.createElement('div'); shareDialog.classList.add('share-dialog', 'hidden'); shareDialog.id = 'share-dialog'; shareDialog.innerHTML = `
Share file
Item:

Share options

`; i18n.translateElement(shareDialog); document.body.appendChild(shareDialog); // Add event listeners for share dialog document.getElementById('share-close-btn')?.addEventListener('click', () => { contextMenus.closeShareDialog(); }); document.getElementById('share-confirm-btn')?.addEventListener('click', async () => { await contextMenus.createSharedLink(); }); document.getElementById('copy-share-btn')?.addEventListener('click', async () => { const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value; if (shareUrl) await fileSharing.copyLinkToClipboard(shareUrl); }); document.getElementById('notify-share-btn')?.addEventListener('click', () => { const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value; if (shareUrl) contextMenus.showEmailNotificationDialog(shareUrl); }); // FIXME make generic function (close all dialog / etc) document.addEventListener('keydown', (e) => { const dialog = document.getElementById('share-dialog'); if (e.key === 'Escape' && !dialog?.classList.contains('hidden')) { contextMenus.closeShareDialog(); } }); shareDialog.addEventListener('click', (e) => { if (e.target === shareDialog) { contextMenus.closeShareDialog(); } }); } // Notification dialog if (!document.getElementById('notification-dialog')) { const notificationDialog = document.createElement('div'); notificationDialog.classList.add('share-dialog', 'hidden'); notificationDialog.id = 'notification-dialog'; notificationDialog.innerHTML = `
Notify shared link

URL:

`; document.body.appendChild(notificationDialog); // Add event listeners for notification dialog document.getElementById('notification-cancel-btn')?.addEventListener('click', () => { contextMenus.closeNotificationDialog(); }); document.getElementById('notification-send-btn')?.addEventListener('click', () => { contextMenus.sendShareNotification(); }); } // Playlist selection dialog if (!document.getElementById('playlist-dialog')) { const playlistDialog = document.createElement('div'); playlistDialog.classList.add('share-dialog', 'hidden'); playlistDialog.id = 'playlist-dialog'; playlistDialog.innerHTML = `
Add to Playlist
`; document.body.appendChild(playlistDialog); document.getElementById('playlist-cancel-btn')?.addEventListener('click', () => { if (contextMenus) contextMenus.closePlaylistDialog(); }); } // Assign events to menu items if (contextMenus) { contextMenus.assignMenuEvents(); } else { console.warn('contextMenus module not loaded'); } }, /** * Set up drag and drop functionality */ setupDragAndDrop() { // prepare area to build dragged elements this.dragPreview = document.createElement('div'); this.dragPreview.className = 'drag-preview'; document.body.appendChild(this.dragPreview); this.draggedItems = null; const dropzone = document.getElementById('dropzone'); /** * * @param {DataTransfer} dataTransfer * @returns {Promise} */ 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; /** @type {Array<{file: File, relativePath: string}>} */ const out = []; /** * @param {FileSystemEntry} entry * @param {string} prefix */ const walkEntry = async (entry, prefix = '') => { if (!entry) return; if (entry.isFile) { await new Promise((resolve) => { /** @type {FileSystemFileEntry} */ (entry).file( (/** @type {File} */ file) => { out.push({ file, relativePath: `${prefix}${file.name}` }); resolve(undefined); }, () => resolve(undefined) ); }); return; } if (entry.isDirectory) { const dirPrefix = `${prefix}${entry.name}/`; const reader = /** @type {FileSystemDirectoryEntry} */ (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(); dropzone.classList.add('active'); }); dropzone?.addEventListener('dragleave', () => { dropzone.classList.remove('active'); }); // remove previous hack e._oxiHandled // WeakSet will automatically garbage collect entry const handledDropEvents = new WeakSet(); dropzone?.addEventListener('drop', async (e) => { e.preventDefault(); e.stopPropagation(); // Prevent bubbling to document's drop handler (avoids double upload) handledDropEvents.add(e); // Mark as handled for document-level fallback dropzone.classList.remove('active'); if (!e.dataTransfer) 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?.includes('/')); if (hasFolderStructure) { fileOps.uploadFolderEntries(droppedEntries); } else { fileOps.uploadFiles(droppedEntries.map((x) => x.file)); } setTimeout(() => { dropzone?.classList.add('hidden'); }, 500); return; } // Detect folder drops: files from folder drops have webkitRelativePath set const hasRelativePaths = Array.from(e.dataTransfer.files).some((f) => f.webkitRelativePath?.includes('/')); if (hasRelativePaths) { fileOps.uploadFolderFiles(e.dataTransfer.files); } else { fileOps.uploadFiles(e.dataTransfer.files); } } setTimeout(() => { dropzone?.classList.add('hidden'); }, 500); }); // Document-wide drag and drop — only active in the Files section document.addEventListener('dragover', (e) => { e.preventDefault(); if (!e.dataTransfer) return; if (e.dataTransfer.types.includes('Files') && app.currentSection === 'files') { dropzone?.classList.remove('hidden'); dropzone?.classList.add('active'); } }); document.addEventListener('dragleave', (e) => { if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { dropzone?.classList.remove('active'); setTimeout(() => { if (!dropzone?.classList.contains('active')) { dropzone?.classList.add('hidden'); } }, 100); } }); document.addEventListener('drop', async (e) => { e.preventDefault(); dropzone?.classList.remove('active'); // Skip if already handled by the dropzone handler (defensive against bubble leaks) if (handledDropEvents.has(e)) return; if (!e.dataTransfer) return; if (app.currentSection !== 'files') { if (e.dataTransfer.types.includes('Files')) { this.showNotification(i18n.t('notifications.upload_files_section_title'), i18n.t('notifications.upload_files_section_body')); } 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?.includes('/')); if (hasFolderStructure) { fileOps.uploadFolderEntries(droppedEntries); } else { fileOps.uploadFiles(droppedEntries.map((x) => x.file)); } setTimeout(() => { dropzone?.classList.add('hidden'); }, 500); return; } // Detect folder drops: files from folder drops have webkitRelativePath set const hasRelativePaths = Array.from(e.dataTransfer.files).some((f) => f.webkitRelativePath?.includes('/')); if (hasRelativePaths) { fileOps.uploadFolderFiles(e.dataTransfer.files); } else { fileOps.uploadFiles(e.dataTransfer.files); } } setTimeout(() => { dropzone?.classList.add('hidden'); }, 500); }); }, /** * Switch to grid view */ switchToGridView() { this._hydrateViewIfNeeded(); app.currentView = 'grid'; localStorage.setItem('oxicloud-view', 'grid'); syncViewContainers(); }, /** * Switch to list view */ switchToListView() { this._hydrateViewIfNeeded(); app.currentView = 'list'; localStorage.setItem('oxicloud-view', 'list'); syncViewContainers(); }, /** * Update breadcrumb navigation from the breadcrumbPath array. * Renders: Home > folder1 > folder2 > ... * Each segment is clickable to navigate back to that level. */ updateBreadcrumb() { const breadcrumb = document.querySelector('.breadcrumb'); if (breadcrumb) { breadcrumb.innerHTML = ''; } const path = app.breadcrumbPath; // [{id, name}, ...] // -- Home icon (always present, clickable to go to root) -- const homeIcon = document.createElement('span'); homeIcon.className = 'breadcrumb-item breadcrumb-home'; homeIcon.innerHTML = ''; homeIcon.title = i18n.t('breadcrumb.home'); // Home is always clickable if we have a home folder if (app.userHomeFolderId) { homeIcon.classList.add('breadcrumb-link'); homeIcon.addEventListener('click', () => { app.breadcrumbPath = []; app.currentPath = app.userHomeFolderId; this.updateBreadcrumb(); loadFiles(); }); } breadcrumb?.appendChild(homeIcon); // -- Root/Home + Intermediate + current segments -- // NOTE: The home folder entry is added by rebuildBreadCrumb() (filesView.js) when it // reaches the root folder during traversal. updateBreadcrumb() just renders app.breadcrumbPath // as-is — no implicit mutation. This allows shared-folder navigation to show only the // reachable subtree without the home prefix leaking in. path.forEach((segment, index) => { const isLast = index === path.length - 1; // Separator const separator = document.createElement('span'); separator.className = 'breadcrumb-separator'; separator.textContent = '>'; breadcrumb?.appendChild(separator); // Segment item const item = document.createElement('span'); item.className = 'breadcrumb-item'; item.textContent = segment.name; item.dataset.folderId = segment.id; if (!isLast) { // Intermediate segment: clickable – truncate path to this level item.classList.add('breadcrumb-link'); item.addEventListener('click', () => { app.breadcrumbPath = path.slice(0, index + 1); app.currentPath = segment.id; this.updateBreadcrumb(); loadFiles(); }); // can drag files on this folder // dragover – only folders are valid drop targets item.addEventListener('dragover', (e) => { const card = /** @type {HTMLElement} */ (e.target).closest('span'); if (!card?.dataset.folderId) return; e.preventDefault(); card.classList.add('drop-target'); }); // dragleave item.addEventListener('dragleave', (e) => { console.log('dragleave ', e); const card = /** @type {HTMLElement} */ (e.target).closest('span'); if (!card?.dataset.folderId) return; card.classList.remove('drop-target'); }); // drop – only folders accept drops item.addEventListener('drop', async (e) => { const card = /** @type {HTMLElement} */ (e.target).closest('span'); if (!card) return; const targetFolderId = card.dataset.folderId; if (!targetFolderId) return; e.preventDefault(); card.classList.remove('drop-target'); const action = e.dataTransfer?.dropEffect; if (action) { await this._dropToFolder(action, targetFolderId, e.dataTransfer); } }); } else { // Last segment: current location, not clickable item.classList.add('breadcrumb-current'); } breadcrumb?.appendChild(item); }); }, /** * Check if a file can be previewed in the viewer * @param {FileItem} file * @returns {boolean} */ isViewableFile(file) { return uiFileTypes.isViewableFile(file); }, /** * Get FontAwesome icon class for a filename based on its extension. * Used as fallback when the backend DTO doesn't include icon_class * (e.g. trash items). * @param {string} fileName */ getIconClass(fileName) { return uiFileTypes.getIconClass(fileName); }, /** * Get CSS special class for icon styling based on filename extension. * Used as fallback when the backend DTO doesn't include icon_special_class. * @param {string} fileName */ getIconSpecialClass(fileName) { return uiFileTypes.getIconSpecialClass(fileName); }, /** * Show notification * @param {string} title - Notification title * @param {string} message - Notification message */ showNotification(title, message) { uiNotifications.show(title, message); }, /** * Close folder context menu */ closeContextMenu() { const menu = document.getElementById('folder-context-menu'); if (menu) { menu.classList.add('hidden'); app.contextMenuTargetFolder = null; } }, /** * Close file context menu */ closeFileContextMenu() { const menu = document.getElementById('file-context-menu'); if (menu) { menu.classList.add('hidden'); app.contextMenuTargetFile = null; } }, /* ================================================================ * Data store + event delegation (replaces per-item listeners) * ================================================================ */ /** @type {Map} item data keyed by id */ _items: new Map(), /** @type {FolderItem[]} last rendered folder dataset */ _lastFolders: [], /** @type {FileItem[]} last rendered file dataset */ _lastFiles: [], /** @type {boolean} */ _delegationReady: false, _getActiveView() { if (app && app.currentView === 'list') return 'list'; if (app && app.currentView === 'grid') return 'grid'; const stored = localStorage.getItem('oxicloud-view'); return stored === 'list' ? 'list' : 'grid'; }, /** * @param {FolderItem[]} folders */ _renderFoldersToView(folders) { if (!Array.isArray(folders) || folders.length === 0) return; const target = document.getElementById('files-list'); if (!target) return; const frag = document.createDocumentFragment(); for (const folder of folders) { try { frag.appendChild(this._createFolderItem(folder)); } catch (e) { console.warn(`Error building folder item `, folder, `reason: `, e); } } target.appendChild(frag); }, /** * @param {FileItem[]} files */ _renderFilesToView(files) { if (!Array.isArray(files) || files.length === 0) return; const target = document.getElementById('files-list'); if (!target) return; const frag = document.createDocumentFragment(); for (const file of files) { try { frag.appendChild(this._createFileItem(file)); } catch (e) { console.warn(`Error building file item `, file, `reason: `, e); } } target.appendChild(frag); }, /** * @param {any[]} arr * @param {any} item */ _upsertById(arr, item) { if (!Array.isArray(arr) || !item?.id) return; const idx = arr.findIndex((x) => x && x.id === item.id); if (idx >= 0) { arr[idx] = item; } else { arr.push(item); } }, /** * handle the drop * @param {string} action copy|move * @param {string} targetFolderId the target * @param {any} dataTransfer fallback if nothing is selected */ async _dropToFolder(action, targetFolderId, dataTransfer) { const selection = multiSelect.getSelection(targetFolderId); multiSelect.clear(); if (selection.fileIds.length === 0 && selection.folderIds.length === 0) { // try to use dataTransfer (direct move without selection) const id = dataTransfer.getData('text/plain'); const isFolder = dataTransfer.getData('application/oxicloud-folder') === 'true'; if (isFolder && id === targetFolderId) { console.log('nothing to do'); return; //nothing to do } // append current item to selection if (isFolder) { console.log(`adding ${id} as folder`); selection.folderIds.push(id); } else { console.log(`adding ${id} as file`); selection.fileIds.push(id); } } console.log(`request ${action} of: `, selection); /* TODO do we prefer use atomic operation on 1 item ? like: await fileOps.moveFolder(sourceId, targetFolderId); await fileOps.moveFile(sourceId, targetFolderId); */ /** @type {BatchResult} */ let result; switch (action) { case 'copy': result = await fileOps.batchCopy(selection.fileIds, selection.folderIds, targetFolderId); break; case 'move': result = await fileOps.batchMove(selection.fileIds, selection.folderIds, targetFolderId); // redraw directory if (result.success > 0) loadFiles(); break; default: console.error(`drag and drop: action ${action} unknown`); return; } multiSelect.showBatchResult(action, result); console.log(result); }, _hydrateViewIfNeeded() { // 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-list .file-item'); if (!hasAnyRenderedItem) return; // FIXME: thre is the header... const listView = document.getElementById('files-list'); if (!listView) return; if (listView.children.length > 1) return; this._renderFoldersToView(this._lastFolders); this._renderFilesToView(this._lastFiles); }, /** * Attach a fixed set of delegated event listeners to the two * container elements (files-list). * Called once – idempotent. */ initDelegation() { if (this._delegationReady) return; const filesList = document.getElementById('files-list'); if (!filesList) return; this._delegationReady = true; // ── helpers ──────────────────────────────────────────────── /** @param {HTMLDivElement} card */ const itemInfo = (card) => { if (!card) return null; const fileId = card.dataset.fileId; if (fileId) return { type: 'file', id: fileId, name: card.dataset.fileName, data: this._items.get(fileId) }; const folderId = card.dataset.folderId; if (folderId) return { type: 'folder', id: folderId, name: card.dataset.folderName, data: this._items.get(folderId) }; return null; }; /** @param {FileItem} file */ const openFile = async (file) => { if (!file) return; if (recent) { document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } })); } // WOPI editor intercept: open Office documents in the WOPI editor // But NOT image files - those should be previewed in the inline viewer const ext = (file.name || '').split('.').pop().toLowerCase(); const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'heic', 'heif', 'avif', 'tiff']; const isImage = file.mime_type?.startsWith('image/') || imageExts.includes(ext); try { if (!isImage && wopiEditor && (await wopiEditor.canEdit(file.name))) { await wopiEditor.openInModal(file.id, file.name, 'edit'); return; } } catch (e) { console.warn(`WOPI Editor failed, falling bck to classic view `, e); } if (this.isViewableFile(file) || isImage) { if (inlineViewer) { inlineViewer.openFile(file); // update history app.viewFile = file.id; updateHistory(false); } else { fileOps.downloadFile(file.id, file.name); } } else { fileOps.downloadFile(file.id, file.name); } }; /** @param {HTMLElement} card */ const navigateFolder = (card) => { const folderId = card.dataset.folderId; const folderName = card.dataset.folderName; if (app.currentSection === 'favorites' || app.currentSection === 'recent') { switchToFilesSection(); app.currentPath = folderId; loadFiles(); return; } if (app.currentSection === 'sharedwithme') { // Activate Files UI (nav, breadcrumb, actions bar) without // resetting the path — the shared folder becomes the entry point. activateFilesUI(); } app.breadcrumbPath.push({ id: folderId, name: folderName }); app.currentPath = folderId; this.updateBreadcrumb(); loadFiles(); }; /** * @param {HTMLElement} card * @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info */ const setContextTarget = (card, info) => { if (info.type === 'folder') { app.contextMenuTargetFolder = /** @type {FolderItem} */ ({ id: info.id, name: card.dataset.folderName, parent_id: card.dataset.parentId || '' }); } else { const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id)); app.contextMenuTargetFile = /** @type {FileItem} */ ({ id: info.id, name: card.dataset.fileName, folder_id: card.dataset.folderId || '', mime_type: fileData?.mime_type || null }); } }; // ── click (open / navigate; select only via checkbox) ── filesList.addEventListener('click', (e) => { const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card) return; if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) { e.stopPropagation(); e.preventDefault(); const info = itemInfo(card); if (!info) return; setContextTarget(card, info); const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu'; showContextMenuAtElement(/** @type {HTMLElement} */ (e.target).closest('.file-actions'), menuId); return; } if (/** @type {HTMLElement} */ (e.target).closest('.checkbox-cell')) { toggleCardSelection(card, e); return; } // Favorite star – handled by direct onclick on the button if (/** @type {HTMLElement} */ (e.target).closest('.favorite-star')) return; // Single-click opens/navigates (selection is only via checkbox) const info = itemInfo(card); if (!info) return; // use modifier key to select/deselect item // note: shift key is used in multiselect // note: on MacOS, ctrl Key is used to convert click into right click, which invoke the `contextmenu` event if (e.metaKey || e.altKey || e.ctrlKey) { toggleCardSelection(card, e); return; } // shiftkey is used to complete selection if (e.shiftKey && multiSelect) { multiSelect.handleToggleItem(card, e); return; } if (info.type === 'folder') { navigateFolder(card); } else { openFile(/** @type {FileItem} */ (info.data)); } }); // ── GRID: dblclick (navigate / open) ────────────────────── filesList.addEventListener('dblclick', (e) => { // Single-click already handles open/navigate. // Prevent duplicate actions on double-click. e.preventDefault(); }); // ── shared events ────────────────────── filesList.addEventListener('contextmenu', (e) => { const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card) return; e.preventDefault(); const info = itemInfo(card); if (!info) return; setContextTarget(card, info); const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu'; const menu = document.getElementById(menuId); contextMenus.sync(); if (menu) { menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; menu.classList.remove('hidden'); } }); // dragstart filesList.addEventListener('dragstart', (e) => { const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card) { e.preventDefault(); return; } const info = itemInfo(card); if (!info) { e.preventDefault(); return; } if (!e.dataTransfer) return; e.dataTransfer.setData('text/plain', info.id); if (info.type === 'folder') { e.dataTransfer.setData('application/oxicloud-folder', 'true'); } // allow copy or move (handled by the browser) e.dataTransfer.effectAllowed = 'copyMove'; this.draggedItems = document.createElement('div'); this.draggedItems.className = 'dragged-items'; let selectedCardFromList = filesList.querySelectorAll(`div.selected > div.name-cell`); if (selectedCardFromList.length === 0) { // fallback to current element selectedCardFromList = card.querySelectorAll('div.name-cell'); } let index = 0; const maxElements = 4; let lastItemDiv = null; while (index < selectedCardFromList.length && index < maxElements) { const iconCell = document.createElement('div'); const icon = selectedCardFromList[index].getElementsByClassName('file-icon').item(0)?.cloneNode(true); if (icon) { iconCell.appendChild(icon); iconCell.querySelectorAll('img')?.forEach((img) => { img.loading = 'eager'; }); } const nameCell = document.createElement('div'); const name = selectedCardFromList[index].getElementsByTagName('span').item(0)?.cloneNode(true); if (name) { nameCell.appendChild(name); } const div = document.createElement('div'); div.className = 'file-item'; div.appendChild(iconCell); div.appendChild(nameCell); this.draggedItems.appendChild(div); index += 1; lastItemDiv = div; } let downloadUrl; let nameEncoded; // tells Browser URL to call to drop selection on operating system (desktop, file manager etc) // will generate a zipfile if multiple if (selectedCardFromList.length === 1) { // only 1 file if (info.type === 'file') { nameEncoded = info.name.replaceAll(/:/g, '-'); // issue is that DownloadURL is using : as separator; downloadUrl = `${window.location.origin}/api/files/${info.id}`; } else { // directory into ZIP nameEncoded = info.name.replaceAll(/:/g, '-').concat('.zip'); downloadUrl = `${window.location.origin}/api/folders/${info.id}/download?format=zip`; } } else { // must use ZIP container // TODO better naming like ("selection in ${parent.name}") modulo i18n ? ... const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-'); nameEncoded = `oxicloud ${now}.zip`; /** @type {string[]} */ const folders = []; /** @type {string[]} */ const files = []; /** @type {NodeListOf} */ (filesList.querySelectorAll(`div.selected`)).forEach((e) => { const item = itemInfo(e); if (item.type === 'file') { files.push(item.id); } else { folders.push(item.id); } }); downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${files.join(',')}&folder_ids=${folders.join(',')}`; } e.dataTransfer?.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); // if more than 1 item, display the badge if (selectedCardFromList.length > 1) { const badge = document.createElement('span'); badge.className = 'dragged-items-badge'; badge.innerText = `${selectedCardFromList.length}`; this.draggedItems.appendChild(badge); } // if more than maxElements display the fading if (selectedCardFromList.length > maxElements) { lastItemDiv?.classList.add('fading'); } this.dragPreview.appendChild(this.draggedItems); e.dataTransfer.setDragImage(this.draggedItems, 0, 0); }); // dragend filesList.addEventListener('dragend', (_e) => { this.dragPreview.removeChild(this.draggedItems); document.querySelectorAll('.drop-target').forEach((el) => { el.classList.remove('drop-target'); }); }); // dragover – only folders are valid drop targets filesList.addEventListener('dragover', (e) => { const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card || card.dataset.fileId) return; if (!card.dataset.folderId) return; e.preventDefault(); card.classList.add('drop-target'); }); // dragleave filesList.addEventListener('dragleave', (e) => { const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card || card.dataset.fileId) return; card.classList.remove('drop-target'); }); // drop – only folders accept drops filesList.addEventListener('drop', async (e) => { const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card || card.dataset.fileId) return; const targetFolderId = card.dataset.folderId; if (!targetFolderId) return; e.preventDefault(); card.classList.remove('drop-target'); if (!e.dataTransfer) return; const action = e.dataTransfer.dropEffect; await this._dropToFolder(action, targetFolderId, e.dataTransfer); }); }, /* ================================================================ * Favorite star helper – attaches a direct click handler to a * star `; if (app.currentPath !== '') { el.setAttribute('draggable', 'true'); } this._bindStarClick(el); return el; }, /** * Create a grid card for a file * @param {FileItem} file */ _createFileItem(file) { 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 ? i18n.t(`files.file_types.${cat.toLowerCase()}`) || cat : i18n.t('files.file_types.document'); const fileSize = file.size_formatted || formatFileSize(file.size); const formattedDate = formatDateTime(file.modified_at); const isFav = favorites?.isFavorite(file.id, 'file'); const isShared = grants.getOutgoingGrantsFor('file', file.id).length > 0; //const isShared = sharedView.isShared(file.id, 'file'); const canThumbnail = thumbnail.canHandle(file); const el = document.createElement('div'); el.className = 'file-item'; el.dataset.fileId = file.id; el.dataset.fileName = file.name; el.dataset.folderId = file.folder_id || ''; if (file.path) el.dataset.path = file.path; el.setAttribute('draggable', 'true'); el.innerHTML = `
${canThumbnail ? `` : ''}
${escapeHtml(file.name)}
${typeLabel}
${fileSize}
${formattedDate}
`; var thumb = /** @type {HTMLImageElement} */ (el.querySelector('.file-thumb')); if (thumb) { thumb.addEventListener('error', () => { console.log(`thumbnail not found for "${file.name}", try to generate it...`); thumb.classList.add('hidden'); thumbnail.queueGenerate(file, (dataUrl) => { thumb.src = dataUrl; thumb.classList.remove('hidden'); }); }); } this._bindStarClick(el); return el; }, /* ================================================================ * Batch rendering with DocumentFragment * ================================================================ */ resetFilesList() { const filesList = document.getElementById('files-list'); const filesContainerError = document.getElementById('files-container-error'); if (!filesList) return; filesList.innerHTML = `
Name
Type
Size
Modified
`; i18n.translateElement(filesList); filesList.classList.remove('hidden'); filesContainerError?.classList.add('hidden'); }, showEmptyList() { this.showError(`

`); }, /** * * @param {string} content * */ showError(content) { const filesContainerError = document.getElementById('files-container-error'); const filesList = document.getElementById('files-list'); if (filesContainerError) filesContainerError.innerHTML = content; i18n.translateElement(filesContainerError); filesContainerError?.classList.remove('hidden'); filesList?.classList.add('hidden'); }, /** * Render an array of folders into both grid and list views * using DocumentFragment for minimal reflows. * * @param {FolderItem[]} folders */ renderFolders(folders) { if (!this._delegationReady) this.initDelegation(); const safeFolders = Array.isArray(folders) ? folders : []; this._lastFolders = safeFolders.slice(); for (const folder of safeFolders) { this._items.set(folder.id, folder); } this._renderFoldersToView(safeFolders); }, /** * Render an array of files into both grid and list views * using DocumentFragment for minimal reflows. * @param {FileItem[]} files */ renderFiles(files) { if (!this._delegationReady) this.initDelegation(); const safeFiles = Array.isArray(files) ? files : []; this._lastFiles = safeFiles.slice(); for (const file of safeFiles) { this._items.set(file.id, file); } this._renderFilesToView(safeFiles); }, /* ================================================================ * Single-item add (backward-compatible API for post-upload, etc.) * ================================================================ */ /** * Add a single folder to the active view. * @param {FolderItem} folder */ addFolderToView(folder) { if (!this._delegationReady) this.initDelegation(); // Duplicate guard if (document.querySelector(`.file-item[data-folder-id="${folder.id}"]`)) { console.log(`Folder ${folder.name} (${folder.id}) already exists in the view, not duplicating`); return; } this._clearEmptyState(); this._items.set(folder.id, folder); this._upsertById(this._lastFolders, folder); this._renderFoldersToView([folder]); }, /** * Add a single file to the active view. * @param {FileItem} file */ addFileToView(file) { if (!this._delegationReady) this.initDelegation(); // Duplicate guard if (document.querySelector(`.file-item[data-file-id="${file.id}"]`)) { console.log(`File ${file.name} (${file.id}) already exists in the view, not duplicating`); return; } this._clearEmptyState(); this._items.set(file.id, file); this._upsertById(this._lastFiles, file); this._renderFilesToView([file]); }, /** * If the empty-state placeholder is showing, switch back to the file list. * Called before adding any new item so the card is not appended to a hidden list. */ _clearEmptyState() { const filesList = document.getElementById('files-list'); const filesContainerError = document.getElementById('files-container-error'); if (filesList?.classList.contains('hidden')) { filesList.classList.remove('hidden'); filesContainerError?.classList.add('hidden'); } } }; // --- Global helper functions for card interactions --- /** * Toggle selection state of a file/folder card. * Routes through the multiSelect module so batch actions know about selected items. * @param {HTMLDivElement} card * @param {MouseEvent} event */ function toggleCardSelection(card, event) { if (multiSelect) { multiSelect.handleToggleItem(card, event); } else { card.classList.toggle('selected'); } } /** * Show the context menu anchored next to a trigger element (the 3-dot button). * @param {HTMLElement} triggerElement * @param {string} menuId */ function showContextMenuAtElement(triggerElement, menuId) { // Hide any open menus first document.querySelectorAll('.context-menu').forEach((m) => { m.classList.add('hidden'); }); const menu = document.getElementById(menuId); if (!menu) return; const rect = triggerElement.getBoundingClientRect(); const menuWidth = 200; // approximate // Position below the trigger, aligned to the right edge let left = rect.right - menuWidth + window.scrollX; let top = rect.bottom + 4 + window.scrollY; // Keep inside viewport if (left < 8) left = 8; if (top + 300 > window.innerHeight + window.scrollY) { top = rect.top - 4 + window.scrollY; // flip above if no room } contextMenus.sync(); menu.style.left = `${left}px`; menu.style.top = `${top}px`; menu.classList.remove('hidden'); } /** * Rubber band (lasso) selection — click + drag on empty grid area * to draw a rectangle and select all cards it touches. */ function initRubberBandSelection() { // Create the visual rectangle element once let selRect = document.getElementById('selection-rect'); if (!selRect) { selRect = document.createElement('div'); selRect.id = 'selection-rect'; selRect.className = 'selection-rect'; document.body.appendChild(selRect); } let active = false; let startX = 0, startY = 0; // We listen on the whole files-container (covers grid + empty space) const container = document.querySelector('.files-container') || document.getElementById('files-list'); if (!container) return; container.addEventListener('mousedown', (e) => { if (!(e instanceof MouseEvent)) return; // Only start if clicking empty area (not on a card, button, menu, input…) if (e.button !== 0) return; // left click only const target = /** @type {Element} */ (e.target); if ( target.closest('.file-item') || target.closest('.context-menu') || target.closest('.upload-dropdown') || target.closest('button') || target.closest('input') || target.closest('.breadcrumb') || target.closest('.list-header') ) return; active = true; startX = e.clientX; startY = e.clientY; selRect.style.left = `${startX}px`; selRect.style.top = `${startY}px`; selRect.style.width = '0px'; selRect.style.height = '0px'; selRect.style.display = 'none'; // show only after a small movement e.preventDefault(); // prevent text selection }); document.addEventListener('mousemove', (e) => { if (!active) return; const curX = e.clientX; const curY = e.clientY; const left = Math.min(startX, curX); const top = Math.min(startY, curY); const width = Math.abs(curX - startX); const height = Math.abs(curY - startY); // Only show the rect after a small threshold to avoid flicker on click if (width > 5 || height > 5) { selRect.style.display = 'block'; } selRect.style.left = `${left}px`; selRect.style.top = `${top}px`; selRect.style.width = `${width}px`; selRect.style.height = `${height}px`; // Highlight cards that intersect with the rectangle const rectBounds = { left, top, right: left + width, bottom: top + height }; document.querySelectorAll('#files-list .file-item').forEach((card) => { const cardRect = card.getBoundingClientRect(); const intersects = cardRect.left < rectBounds.right && cardRect.right > rectBounds.left && cardRect.top < rectBounds.bottom && cardRect.bottom > rectBounds.top; if (intersects) { card.classList.add('selected'); // Sync with multiSelect module if (multiSelect) { const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card)); if (info) multiSelect.select(info.id, info.name, info.type, info.parentId); } } else { card.classList.remove('selected'); // Deselect from multiSelect module if (multiSelect) { const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card)); if (info) multiSelect.deselect(info.id); } } }); }); document.addEventListener('mouseup', () => { if (!active) return; active = false; const hadSelection = selRect.style.display === 'block'; selRect.style.display = 'none'; // Update the batch bar after rubber band selection completes if (multiSelect) multiSelect._syncUI(); // Suppress the click event that follows mouseup so the global // deselect handler doesn't immediately clear the selection. if (hadSelection) { requestAnimationFrame(() => {}); } }); } // Initialize rubber band once DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initRubberBandSelection); } else { initRubberBandSelection(); } /** * Show a modern confirm dialog (replaces native confirm()) * @param {Object} options * @param {string} options.title - Dialog title * @param {string} options.message - Dialog message/body * @param {string} [options.confirmText='Confirmar'] - Text for confirm button * @param {string} [options.cancelText='Cancelar'] - Text for cancel button * @param {boolean} [options.danger=false] - Use danger styling (red) * @returns {Promise} true if confirmed, false if cancelled */ function showConfirmDialog({ title, message, confirmText, cancelText, danger = true }) { const ct = confirmText || i18n.t('actions.delete'); const cc = cancelText || i18n.t('actions.cancel'); const t = title || i18n.t('dialogs.confirm_title'); return new Promise((resolve) => { // Remove any previous confirm dialog const prev = document.getElementById('confirm-dialog-overlay'); if (prev) prev.remove(); const overlay = document.createElement('div'); overlay.id = 'confirm-dialog-overlay'; overlay.className = 'confirm-dialog'; overlay.innerHTML = `
${t}
${message || ''}
`; document.body.appendChild(overlay); // Force layout then show requestAnimationFrame(() => { overlay.classList.add('active'); }); /** @param {boolean} result */ const cleanup = (result) => { overlay.classList.remove('active'); setTimeout(() => overlay.remove(), 200); resolve(result); }; overlay.querySelector('.confirm-dialog-cancel')?.addEventListener('click', () => cleanup(false)); overlay.querySelector('.confirm-dialog-ok')?.addEventListener('click', () => cleanup(true)); overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); }); } export { initRubberBandSelection, showConfirmDialog, showContextMenuAtElement, toggleCardSelection, ui };