Files
Oxicloud/static/js/app/ui.js
T

1741 lines
70 KiB
JavaScript
Raw Normal View History

2025-03-19 23:28:29 +01:00
/**
* OxiCloud - UI Module
* This file handles UI-related functions, view toggling, and interface interactions
*/
2026-03-25 18:49:38 +01:00
// @ts-check
import { shareModal } from '../components/shareModal.js';
import { createUserVignette } from '../components/userVignette.js';
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 { thumbnail } from '../features/thumbnail.js';
2026-05-22 10:15:25 +02:00
import { grants } from '../model/grants.js';
import { systemUsers } from '../model/systemUsers.js';
import { loadFiles } from './filesView.js';
import { updateHistory } from './main.js';
2026-05-22 10:15:25 +02:00
import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js';
import { app } from './state.js';
import { uiFileTypes } from './uiFileTypes.js';
import { uiNotifications } from './uiNotifications.js';
2026-05-07 23:40:02 +02:00
/**
* @import {FileItem, FolderItem} from '../core/types.js'
* @import {BatchResult} from '../features/files/fileOperations.js'
*/
2025-03-19 23:28:29 +01:00
// UI Module
const ui = {
2026-05-07 23:40:02 +02:00
/** @type {HTMLDivElement | null} */
dragPreview: null,
/** @type {HTMLDivElement | null} */
draggedItems: null,
/**
* Whether the Owner column is currently visible.
* Tracked so that newly rendered items can stamp the correct initial class.
*/
_ownerVisible: false,
2025-03-19 23:28:29 +01:00
/**
* Initialize context menus and dialogs
*/
initializeContextMenus() {
// Folder context menu
if (!document.getElementById('folder-context-menu')) {
const folderMenu = document.createElement('div');
2026-04-24 23:37:14 +02:00
folderMenu.classList.add('context-menu', 'hidden');
2025-03-19 23:28:29 +01:00
folderMenu.id = 'folder-context-menu';
folderMenu.innerHTML = `
2025-04-02 01:22:05 +02:00
<div class="context-menu-item" id="download-folder-option">
<i class="fas fa-download"></i> <span data-i18n="actions.download">Download</span>
2025-04-02 01:22:05 +02:00
</div>
2025-04-02 03:43:44 +02:00
<div class="context-menu-item" id="favorite-folder-option">
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
2025-04-02 03:43:44 +02:00
</div>
2026-02-08 22:44:42 +01:00
<div class="context-menu-item" id="share-folder-option">
2026-05-22 10:15:25 +02:00
<i class="fas fa-oxiexport"></i> <span data-i18n="actions.share">Share</span>
2026-02-08 22:44:42 +01:00
</div>
<div class="context-menu-separator"></div>
2025-03-19 23:28:29 +01:00
<div class="context-menu-item" id="rename-folder-option">
<i class="fas fa-pen"></i> <span data-i18n="actions.rename">Rename</span>
2025-03-19 23:28:29 +01:00
</div>
<div class="context-menu-item" id="move-folder-option">
<i class="fas fa-arrows-alt"></i> <span data-i18n="actions.move">Move to...</span>
2025-03-19 23:28:29 +01:00
</div>
2026-02-08 22:44:42 +01:00
<div class="context-menu-separator"></div>
<div class="context-menu-item context-menu-item-danger" id="delete-folder-option">
<i class="fas fa-trash-alt"></i> <span data-i18n="actions.delete">Delete</span>
2025-03-19 23:28:29 +01:00
</div>
`;
document.body.appendChild(folderMenu);
i18n.translateElement(folderMenu);
2025-03-19 23:28:29 +01:00
}
// File context menu
if (!document.getElementById('file-context-menu')) {
const fileMenu = document.createElement('div');
2026-04-24 23:37:14 +02:00
fileMenu.classList.add('context-menu', 'hidden');
2025-03-19 23:28:29 +01:00
fileMenu.id = 'file-context-menu';
fileMenu.innerHTML = `
2025-04-02 05:08:30 +02:00
<div class="context-menu-item" id="view-file-option">
<i class="fas fa-eye"></i> <span data-i18n="actions.view">View</span>
2025-04-02 05:08:30 +02:00
</div>
<div class="context-menu-item hidden" id="wopi-edit-file-option">
<i class="fas fa-file-word"></i> <span>Edit in Office</span>
</div>
<div class="context-menu-item hidden" id="wopi-edit-file-tab-option">
<i class="fas fa-external-link-alt"></i> <span>Edit in Office (new tab)</span>
</div>
2025-04-02 01:22:05 +02:00
<div class="context-menu-item" id="download-file-option">
<i class="fas fa-download"></i> <span data-i18n="actions.download">Download</span>
2025-04-02 01:22:05 +02:00
</div>
<div class="context-menu-item hidden" id="open-parent-folder-option">
<i class="fas fa-folder-open"></i> <span data-i18n="actions.open_parent_folder">Go to parent folder</span>
</div>
2026-02-08 22:44:42 +01:00
<div class="context-menu-separator"></div>
2025-04-02 03:43:44 +02:00
<div class="context-menu-item" id="favorite-file-option">
<i class="fas fa-star"></i> <span data-i18n="actions.favorite">Add to favorites</span>
2025-04-02 03:43:44 +02:00
</div>
2025-03-28 08:09:18 +01:00
<div class="context-menu-item" id="share-file-option">
2026-05-22 10:15:25 +02:00
<i class="fas fa-oxiexport"></i> <span data-i18n="actions.share">Share</span>
2025-03-28 08:09:18 +01:00
</div>
2026-04-08 15:14:03 +03:00
<div class="context-menu-item hidden" id="add-to-playlist-option">
<i class="fas fa-compact-disc"></i> <span data-i18n="music.add_to_playlist">Add to Playlist</span>
</div>
2026-02-08 22:44:42 +01:00
<div class="context-menu-separator"></div>
<div class="context-menu-item" id="rename-file-option">
<i class="fas fa-pen"></i> <span data-i18n="actions.rename">Rename</span>
2026-02-08 22:44:42 +01:00
</div>
2025-03-19 23:28:29 +01:00
<div class="context-menu-item" id="move-file-option">
<i class="fas fa-arrows-alt"></i> <span data-i18n="actions.move">Move to...</span>
2025-03-19 23:28:29 +01:00
</div>
2026-02-08 22:44:42 +01:00
<div class="context-menu-separator"></div>
<div class="context-menu-item context-menu-item-danger" id="delete-file-option">
<i class="fas fa-trash-alt"></i> <span data-i18n="actions.delete">Delete</span>
2025-03-19 23:28:29 +01:00
</div>
`;
document.body.appendChild(fileMenu);
i18n.translateElement(fileMenu);
2025-03-19 23:28:29 +01:00
}
// Move dialog — modern with navigation
2025-03-19 23:28:29 +01:00
if (!document.getElementById('move-file-dialog')) {
const moveDialog = document.createElement('div');
moveDialog.classList.add('rename-dialog', 'hidden');
2025-03-19 23:28:29 +01:00
moveDialog.id = 'move-file-dialog';
moveDialog.innerHTML = `
<div class="rename-dialog-content">
2026-02-08 22:44:42 +01:00
<div class="rename-dialog-header">
<i class="fas fa-arrows-alt dialog-header-icon"></i>
<span data-i18n="dialogs.move_file">Move</span>
2026-02-08 22:44:42 +01:00
</div>
<div class="rename-dialog-body">
<p class="move-dialog-hint" data-i18n="dialogs.select_destination">Select destination folder:</p>
<div id="move-dialog-breadcrumb" class="move-dialog-breadcrumb"></div>
<div id="folder-select-container" class="folder-select-container">
2025-03-19 23:28:29 +01:00
</div>
</div>
<div class="rename-dialog-buttons">
<button class="btn btn-secondary" id="move-cancel-btn" data-i18n="actions.cancel">Cancel</button>
<button class="btn btn-outline" id="copy-confirm-btn" data-i18n="actions.copy">Copy</button>
<button class="btn btn-primary" id="move-confirm-btn" data-i18n="actions.move_to">Move</button>
2025-03-19 23:28:29 +01:00
</div>
</div>
`;
document.body.appendChild(moveDialog);
}
// Share dialog is now handled by shareModal (components/shareModal.js)
2025-03-28 08:09:18 +01:00
// Notification dialog
if (!document.getElementById('notification-dialog')) {
const notificationDialog = document.createElement('div');
notificationDialog.classList.add('share-dialog', 'hidden');
2025-03-28 08:09:18 +01:00
notificationDialog.id = 'notification-dialog';
notificationDialog.innerHTML = `
<div class="share-dialog-content">
2026-02-08 22:44:42 +01:00
<div class="share-dialog-header">
<i class="fas fa-envelope dialog-header-icon"></i>
<span data-i18n="dialogs.notify">Notify shared link</span>
2026-02-08 22:44:42 +01:00
</div>
2025-03-28 08:09:18 +01:00
<p><strong>URL:</strong> <span id="notification-share-url"></span></p>
2025-03-28 08:09:18 +01:00
<div class="form-group">
<label for="notification-email" data-i18n="dialogs.recipient">Recipient:</label>
<input type="email" id="notification-email" placeholder="Email address">
2025-03-28 08:09:18 +01:00
</div>
2025-03-28 08:09:18 +01:00
<div class="form-group">
<label for="notification-message" data-i18n="dialogs.message">Message (optional):</label>
2025-03-28 08:09:18 +01:00
<textarea id="notification-message" rows="3"></textarea>
</div>
2025-03-28 08:09:18 +01:00
<div class="share-dialog-buttons">
<button class="btn btn-secondary" id="notification-cancel-btn" data-i18n="actions.cancel">Cancel</button>
<button class="btn btn-primary" id="notification-send-btn" data-i18n="actions.send">Send</button>
2025-03-28 08:09:18 +01:00
</div>
</div>
`;
document.body.appendChild(notificationDialog);
2025-03-28 08:09:18 +01:00
// Add event listeners for notification dialog
2026-04-30 02:02:27 +02:00
document.getElementById('notification-cancel-btn')?.addEventListener('click', () => {
2025-03-28 08:09:18 +01:00
contextMenus.closeNotificationDialog();
});
2026-04-30 02:02:27 +02:00
document.getElementById('notification-send-btn')?.addEventListener('click', () => {
2025-03-28 08:09:18 +01:00
contextMenus.sendShareNotification();
});
}
2025-03-19 23:28:29 +01:00
2026-04-08 15:14:03 +03:00
// Playlist selection dialog
if (!document.getElementById('playlist-dialog')) {
const playlistDialog = document.createElement('div');
playlistDialog.classList.add('share-dialog', 'hidden');
2026-04-08 15:14:03 +03:00
playlistDialog.id = 'playlist-dialog';
playlistDialog.innerHTML = `
<div class="share-dialog-content">
<div class="share-dialog-header">
<i class="fas fa-music dialog-header-icon"></i>
<span data-i18n="music.add_to_playlist">Add to Playlist</span>
</div>
<div id="playlist-dialog-files-info" class="shared-item-info"></div>
<div id="playlist-select-container" class="folder-select-container">
</div>
<div class="rename-dialog-buttons">
<button class="btn btn-secondary" id="playlist-cancel-btn" data-i18n="actions.cancel">Cancel</button>
<button class="btn btn-primary" id="playlist-add-btn" data-i18n="music.add">Add</button>
</div>
</div>
`;
document.body.appendChild(playlistDialog);
2026-04-30 02:02:27 +02:00
document.getElementById('playlist-cancel-btn')?.addEventListener('click', () => {
if (contextMenus) contextMenus.closePlaylistDialog();
2026-04-08 15:14:03 +03:00
});
}
2025-03-19 23:28:29 +01:00
// Assign events to menu items
if (contextMenus) {
contextMenus.assignMenuEvents();
2025-03-28 08:09:18 +01:00
} else {
console.warn('contextMenus module not loaded');
}
2025-03-19 23:28:29 +01:00
},
/**
* Set up drag and drop functionality
*/
setupDragAndDrop() {
2026-03-25 18:49:38 +01:00
// prepare area to build dragged elements
2026-04-07 22:48:59 +02:00
this.dragPreview = document.createElement('div');
this.dragPreview.className = 'drag-preview';
2026-03-25 18:49:38 +01:00
document.body.appendChild(this.dragPreview);
this.draggedItems = null;
2025-03-19 23:28:29 +01:00
const dropzone = document.getElementById('dropzone');
2026-05-07 23:40:02 +02:00
/**
*
* @param {DataTransfer} dataTransfer
* @returns {Promise<any[]|null>}
*/
const collectDroppedEntries = async (dataTransfer) => {
const items = Array.from(dataTransfer?.items || []);
2026-04-07 22:48:59 +02:00
const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean);
if (rootEntries.length === 0) return null;
2026-05-07 23:40:02 +02:00
/** @type {Array<{file: File, relativePath: string}>} */
const out = [];
2026-05-07 23:40:02 +02:00
/**
* @param {FileSystemEntry} entry
* @param {string} prefix
*/
const walkEntry = async (entry, prefix = '') => {
if (!entry) return;
if (entry.isFile) {
await new Promise((resolve) => {
2026-05-07 23:40:02 +02:00
/** @type {FileSystemFileEntry} */ (entry).file(
(/** @type {File} */ file) => {
out.push({ file, relativePath: `${prefix}${file.name}` });
2026-04-30 02:02:27 +02:00
resolve(undefined);
},
2026-04-30 02:02:27 +02:00
() => resolve(undefined)
);
});
return;
}
if (entry.isDirectory) {
const dirPrefix = `${prefix}${entry.name}/`;
2026-05-07 23:40:02 +02:00
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;
};
2025-03-19 23:28:29 +01:00
// Dropzone events
2026-04-30 02:02:27 +02:00
dropzone?.addEventListener('dragover', (e) => {
2025-03-19 23:28:29 +01:00
e.preventDefault();
dropzone.classList.add('active');
});
2026-04-30 02:02:27 +02:00
dropzone?.addEventListener('dragleave', () => {
2025-03-19 23:28:29 +01:00
dropzone.classList.remove('active');
});
2026-04-30 02:02:27 +02:00
// remove previous hack e._oxiHandled
// WeakSet will automatically garbage collect entry
const handledDropEvents = new WeakSet();
dropzone?.addEventListener('drop', async (e) => {
2025-03-19 23:28:29 +01:00
e.preventDefault();
e.stopPropagation(); // Prevent bubbling to document's drop handler (avoids double upload)
2026-04-30 02:02:27 +02:00
handledDropEvents.add(e); // Mark as handled for document-level fallback
2025-03-19 23:28:29 +01:00
dropzone.classList.remove('active');
2026-04-30 02:02:27 +02:00
if (!e.dataTransfer) return;
2025-03-19 23:28:29 +01:00
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) {
2026-04-07 22:50:42 +02:00
const hasFolderStructure = droppedEntries.some((x) => x.relativePath?.includes('/'));
if (hasFolderStructure) {
fileOps.uploadFolderEntries(droppedEntries);
} else {
2026-04-07 22:48:59 +02:00
fileOps.uploadFiles(droppedEntries.map((x) => x.file));
}
setTimeout(() => {
2026-04-24 23:37:14 +02:00
dropzone?.classList.add('hidden');
}, 500);
return;
}
// Detect folder drops: files from folder drops have webkitRelativePath set
2026-04-07 22:50:42 +02:00
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);
}
2025-03-19 23:28:29 +01:00
}
setTimeout(() => {
2026-04-24 23:37:14 +02:00
dropzone?.classList.add('hidden');
}, 500);
2025-03-19 23:28:29 +01:00
});
// Document-wide drag and drop — only active in the Files section
2025-03-19 23:28:29 +01:00
document.addEventListener('dragover', (e) => {
e.preventDefault();
2026-04-30 02:02:27 +02:00
if (!e.dataTransfer) return;
if (e.dataTransfer.types.includes('Files') && app.currentSection === 'files') {
2026-04-24 23:37:14 +02:00
dropzone?.classList.remove('hidden');
dropzone?.classList.add('active');
2025-03-19 23:28:29 +01:00
}
});
document.addEventListener('dragleave', (e) => {
2026-04-07 22:48:59 +02:00
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
2026-04-30 02:02:27 +02:00
dropzone?.classList.remove('active');
2025-03-19 23:28:29 +01:00
setTimeout(() => {
2026-04-30 02:02:27 +02:00
if (!dropzone?.classList.contains('active')) {
2026-04-24 23:37:14 +02:00
dropzone?.classList.add('hidden');
2025-03-19 23:28:29 +01:00
}
}, 100);
}
});
document.addEventListener('drop', async (e) => {
2025-03-19 23:28:29 +01:00
e.preventDefault();
2026-04-30 02:02:27 +02:00
dropzone?.classList.remove('active');
2025-03-19 23:28:29 +01:00
// Skip if already handled by the dropzone handler (defensive against bubble leaks)
2026-04-30 02:02:27 +02:00
if (handledDropEvents.has(e)) return;
2026-04-30 02:02:27 +02:00
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;
}
2025-03-19 23:28:29 +01:00
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) {
2026-04-07 22:50:42 +02:00
const hasFolderStructure = droppedEntries.some((x) => x.relativePath?.includes('/'));
if (hasFolderStructure) {
fileOps.uploadFolderEntries(droppedEntries);
} else {
2026-04-07 22:48:59 +02:00
fileOps.uploadFiles(droppedEntries.map((x) => x.file));
}
setTimeout(() => {
2026-04-24 23:37:14 +02:00
dropzone?.classList.add('hidden');
}, 500);
return;
}
// Detect folder drops: files from folder drops have webkitRelativePath set
2026-04-07 22:50:42 +02:00
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);
}
2025-03-19 23:28:29 +01:00
}
setTimeout(() => {
2026-04-24 23:37:14 +02:00
dropzone?.classList.add('hidden');
2025-03-19 23:28:29 +01:00
}, 500);
});
},
/**
* Switch to grid view
*/
switchToGridView() {
this._hydrateViewIfNeeded();
app.currentView = 'grid';
2025-03-19 23:28:29 +01:00
localStorage.setItem('oxicloud-view', 'grid');
syncViewContainers();
2025-03-19 23:28:29 +01:00
},
/**
* Switch to list view
*/
switchToListView() {
this._hydrateViewIfNeeded();
app.currentView = 'list';
2025-03-19 23:28:29 +01:00
localStorage.setItem('oxicloud-view', 'list');
syncViewContainers();
2025-03-19 23:28:29 +01:00
},
/**
* Show or hide the Owner column. When hidden, no name-resolution calls are made.
* Sections that show owner (SharedWithMe, Favorites) pass `true`; all others `false`.
* @param {boolean} visible
*/
setOwnerColumnVisible(visible) {
this._ownerVisible = visible;
document
.getElementById('files-list')
?.querySelectorAll('.owner-cell')
.forEach((cell) => {
cell.classList.toggle('hidden', !visible);
});
},
/**
* Asynchronously fill every un-resolved `.owner-cell` in the current list with
* the display name for its `data-owner-id` attribute.
*
* Call this after `renderFiles()` / `renderFolders()` in sections where the owner
* column is visible. Idempotent: cells already stamped with `data-owner-resolved`
* are skipped (safe to call on each "Load more" page append).
*
* When the column is hidden nothing calls this function, so `systemUsers` is never
* touched and no address-book requests are issued.
*
* @returns {Promise<void>}
*/
async resolveOwnerCells() {
const filesList = document.getElementById('files-list');
const cells = /** @type {NodeListOf<HTMLElement>} */ (filesList?.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])'));
if (!cells?.length) return;
systemUsers.prefetch(); // warm cache once (idempotent, fire-and-forget)
for (const cell of cells) {
const id = cell.dataset.ownerId;
cell.dataset.ownerResolved = '1';
if (!id) continue;
cell.replaceChildren(createUserVignette(id, 'list'));
}
},
2025-03-19 23:28:29 +01:00
/**
* Update breadcrumb navigation from the breadcrumbPath array.
* Renders: Home > folder1 > folder2 > ...
* Each segment is clickable to navigate back to that level.
2025-03-19 23:28:29 +01:00
*/
updateBreadcrumb() {
2025-03-19 23:28:29 +01:00
const breadcrumb = document.querySelector('.breadcrumb');
2026-04-30 02:02:27 +02:00
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 = '<i class="fas fa-home"></i>';
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;
2026-04-07 22:50:42 +02:00
this.updateBreadcrumb();
loadFiles();
2025-03-27 01:13:34 +01:00
});
}
2026-04-30 02:02:27 +02:00
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
2025-03-19 23:28:29 +01:00
const separator = document.createElement('span');
separator.className = 'breadcrumb-separator';
separator.textContent = '>';
2026-04-30 02:02:27 +02:00
breadcrumb?.appendChild(separator);
2025-03-19 23:28:29 +01:00
// 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;
2026-04-07 22:50:42 +02:00
this.updateBreadcrumb();
loadFiles();
});
// can drag files on this folder
// dragover – only folders are valid drop targets
item.addEventListener('dragover', (e) => {
2026-04-30 02:02:27 +02:00
const card = /** @type {HTMLElement} */ (e.target).closest('span');
2026-04-07 22:50:42 +02:00
if (!card?.dataset.folderId) return;
e.preventDefault();
card.classList.add('drop-target');
});
// dragleave
item.addEventListener('dragleave', (e) => {
2026-04-07 22:48:59 +02:00
console.log('dragleave ', e);
2026-04-30 02:02:27 +02:00
const card = /** @type {HTMLElement} */ (e.target).closest('span');
2026-04-07 22:50:42 +02:00
if (!card?.dataset.folderId) return;
card.classList.remove('drop-target');
});
// drop – only folders accept drops
item.addEventListener('drop', async (e) => {
2026-04-30 02:02:27 +02:00
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');
2026-03-25 18:49:38 +01:00
const action = e.dataTransfer?.dropEffect;
2026-04-30 02:02:27 +02:00
if (action) {
await this._dropToFolder(action, targetFolderId, e.dataTransfer);
}
});
} else {
// Last segment: current location, not clickable
item.classList.add('breadcrumb-current');
}
2026-04-30 02:02:27 +02:00
breadcrumb?.appendChild(item);
});
2025-03-19 23:28:29 +01:00
},
/**
* Check if a file can be previewed in the viewer
2026-05-07 23:40:02 +02:00
* @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).
2026-05-07 23:40:02 +02:00
* @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.
2026-05-07 23:40:02 +02:00
* @param {string} fileName
*/
getIconSpecialClass(fileName) {
return uiFileTypes.getIconSpecialClass(fileName);
},
2025-03-19 23:28:29 +01:00
/**
* Show notification
* @param {string} title - Notification title
* @param {string} message - Notification message
*/
showNotification(title, message) {
uiNotifications.show(title, message);
2025-03-19 23:28:29 +01:00
},
/**
* Close folder context menu
*/
closeContextMenu() {
const menu = document.getElementById('folder-context-menu');
if (menu) {
2026-04-24 23:37:14 +02:00
menu.classList.add('hidden');
app.contextMenuTargetFolder = null;
2025-03-19 23:28:29 +01:00
}
},
/**
* Close file context menu
*/
closeFileContextMenu() {
const menu = document.getElementById('file-context-menu');
if (menu) {
2026-04-24 23:37:14 +02:00
menu.classList.add('hidden');
app.contextMenuTargetFile = null;
2025-03-19 23:28:29 +01:00
}
},
/* ================================================================
* Data store + event delegation (replaces per-item listeners)
* ================================================================ */
2026-05-07 23:40:02 +02:00
/** @type {Map<string, FolderItem | FileItem>} item data keyed by id */
_items: new Map(),
2026-05-07 23:40:02 +02:00
/** @type {FolderItem[]} last rendered folder dataset */
_lastFolders: [],
2026-05-07 23:40:02 +02:00
/** @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';
},
/**
2026-05-07 23:40:02 +02:00
* @param {FolderItem[]} folders
*/
_renderFoldersToView(folders) {
if (!Array.isArray(folders) || folders.length === 0) return;
2026-04-07 22:48:59 +02:00
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);
},
2026-04-07 22:48:59 +02:00
/**
2026-05-07 23:40:02 +02:00
* @param {FileItem[]} files
*/
_renderFilesToView(files) {
if (!Array.isArray(files) || files.length === 0) return;
2026-04-07 22:48:59 +02:00
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);
},
2026-05-07 23:40:02 +02:00
/**
* @param {any[]} arr
* @param {any} item
*/
_upsertById(arr, item) {
2026-04-07 22:50:42 +02:00
if (!Array.isArray(arr) || !item?.id) return;
2026-04-07 22:48:59 +02:00
const idx = arr.findIndex((x) => x && x.id === item.id);
if (idx >= 0) {
arr[idx] = item;
} else {
arr.push(item);
}
},
2026-03-25 18:49:38 +01:00
/**
* 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();
2026-03-25 18:49:38 +01:00
2026-04-07 22:50:42 +02:00
if (selection.fileIds.length === 0 && selection.folderIds.length === 0) {
2026-03-25 18:49:38 +01:00
// 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) {
2026-04-07 22:48:59 +02:00
console.log('nothing to do');
2026-03-25 18:49:38 +01:00
return; //nothing to do
}
// append current item to selection
if (isFolder) {
console.log(`adding ${id} as folder`);
selection.folderIds.push(id);
2026-04-07 22:48:59 +02:00
} else {
2026-03-25 18:49:38 +01:00
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);
*/
2026-05-07 23:40:02 +02:00
/** @type {BatchResult} */
2026-03-25 18:49:38 +01:00
let result;
switch (action) {
2026-04-07 22:48:59 +02:00
case 'copy':
result = await fileOps.batchCopy(selection.fileIds, selection.folderIds, targetFolderId);
2026-03-25 18:49:38 +01:00
break;
2026-04-07 22:48:59 +02:00
case 'move':
result = await fileOps.batchMove(selection.fileIds, selection.folderIds, targetFolderId);
// redraw directory
if (result.success > 0) loadFiles();
2026-03-25 18:49:38 +01:00
break;
default:
console.error(`drag and drop: action ${action} unknown`);
return;
2026-03-25 18:49:38 +01:00
}
multiSelect.showBatchResult(action, result);
2026-04-07 22:48:59 +02:00
console.log(result);
2026-03-25 18:49:38 +01:00
},
_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);
},
2025-03-19 23:28:29 +01:00
/**
* Attach a fixed set of delegated event listeners to the two
* container elements (files-list).
* Called once – idempotent.
2025-03-19 23:28:29 +01:00
*/
initDelegation() {
if (this._delegationReady) return;
const filesList = document.getElementById('files-list');
if (!filesList) return;
this._delegationReady = true;
// ── helpers ────────────────────────────────────────────────
2026-05-07 23:40:02 +02:00
/** @param {HTMLDivElement} card */
const itemInfo = (card) => {
if (!card) return null;
const fileId = card.dataset.fileId;
2026-04-07 22:48:59 +02:00
if (fileId)
return {
type: 'file',
id: fileId,
name: card.dataset.fileName,
2026-04-07 22:50:42 +02:00
data: this._items.get(fileId)
2026-04-07 22:48:59 +02:00
};
const folderId = card.dataset.folderId;
2026-04-07 22:48:59 +02:00
if (folderId)
return {
type: 'folder',
id: folderId,
name: card.dataset.folderName,
2026-04-07 22:50:42 +02:00
data: this._items.get(folderId)
2026-04-07 22:48:59 +02:00
};
return null;
};
2026-05-07 23:40:02 +02:00
/** @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();
2026-04-07 22:48:59 +02:00
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'heic', 'heif', 'avif', 'tiff'];
2026-04-07 22:50:42 +02:00
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;
}
2026-04-07 22:50:42 +02:00
} catch (e) {
console.warn(`WOPI Editor failed, falling bck to classic view `, e);
}
2026-04-07 22:50:42 +02:00
if (this.isViewableFile(file) || isImage) {
if (inlineViewer) {
inlineViewer.openFile(file);
// update history
app.viewFile = file.id;
updateHistory(false);
2026-04-07 22:50:42 +02:00
} else {
fileOps.downloadFile(file.id, file.name);
}
} else {
fileOps.downloadFile(file.id, file.name);
}
};
2026-05-07 23:40:02 +02:00
/** @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;
}
2026-05-22 10:15:25 +02:00
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;
2026-04-07 22:50:42 +02:00
this.updateBreadcrumb();
loadFiles();
};
2025-03-19 23:28:29 +01:00
2026-05-07 23:40:02 +02:00
/**
* @param {HTMLElement} card
* @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info
*/
const setContextTarget = (card, info) => {
if (info.type === 'folder') {
2026-05-07 23:40:02 +02:00
app.contextMenuTargetFolder = /** @type {FolderItem} */ ({
id: info.id,
name: card.dataset.folderName,
2026-04-07 22:48:59 +02:00
parent_id: card.dataset.parentId || ''
2026-05-07 23:40:02 +02:00
});
} else {
2026-05-07 23:40:02 +02:00
const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id));
app.contextMenuTargetFile = /** @type {FileItem} */ ({
id: info.id,
name: card.dataset.fileName,
2026-04-08 15:14:03 +03:00
folder_id: card.dataset.folderId || '',
mime_type: fileData?.mime_type || null
2026-05-07 23:40:02 +02:00
});
}
};
2025-03-19 23:28:29 +01:00
// ── click (open / navigate; select only via checkbox) ──
filesList.addEventListener('click', (e) => {
2026-05-07 23:40:02 +02:00
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return;
2025-03-19 23:28:29 +01:00
2026-04-30 02:02:27 +02:00
if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) {
e.stopPropagation();
e.preventDefault();
const info = itemInfo(card);
if (!info) return;
setContextTarget(card, info);
2026-04-07 22:48:59 +02:00
const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu';
2026-04-30 02:02:27 +02:00
showContextMenuAtElement(/** @type {HTMLElement} */ (e.target).closest('.file-actions'), menuId);
return;
}
2025-03-19 23:28:29 +01:00
2026-04-30 02:02:27 +02:00
if (/** @type {HTMLElement} */ (e.target).closest('.checkbox-cell')) {
toggleCardSelection(card, e);
return;
}
2026-02-08 22:44:42 +01:00
// Favorite star – handled by direct onclick on the button
2026-04-30 02:02:27 +02:00
if (/** @type {HTMLElement} */ (e.target).closest('.favorite-star')) return;
2026-02-08 22:44:42 +01:00
// 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 {
2026-05-07 23:40:02 +02:00
openFile(/** @type {FileItem} */ (info.data));
}
2026-02-08 22:44:42 +01:00
});
// ── GRID: dblclick (navigate / open) ──────────────────────
filesList.addEventListener('dblclick', (e) => {
// Single-click already handles open/navigate.
// Prevent duplicate actions on double-click.
e.preventDefault();
});
// ── shared events ──────────────────────
2026-04-07 22:48:59 +02:00
filesList.addEventListener('contextmenu', (e) => {
2026-05-07 23:40:02 +02:00
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);
2026-04-07 22:48:59 +02:00
const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu';
const menu = document.getElementById(menuId);
2026-05-11 22:26:53 +02:00
contextMenus.sync();
2026-04-30 02:02:27 +02:00
if (menu) {
menu.style.left = `${e.pageX}px`;
menu.style.top = `${e.pageY}px`;
menu.classList.remove('hidden');
}
2025-03-19 23:28:29 +01:00
});
// dragstart
filesList.addEventListener('dragstart', (e) => {
2026-05-07 23:40:02 +02:00
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
2026-04-07 22:48:59 +02:00
if (!card) {
e.preventDefault();
return;
}
const info = itemInfo(card);
2026-04-07 22:48:59 +02:00
if (!info) {
e.preventDefault();
return;
}
2025-03-19 23:28:29 +01:00
2026-04-30 02:02:27 +02:00
if (!e.dataTransfer) return;
e.dataTransfer.setData('text/plain', info.id);
2026-04-07 22:48:59 +02:00
if (info.type === 'folder') {
e.dataTransfer.setData('application/oxicloud-folder', 'true');
}
// allow copy or move (handled by the browser)
2026-04-07 22:48:59 +02:00
e.dataTransfer.effectAllowed = 'copyMove';
2026-04-07 22:50:42 +02:00
this.draggedItems = document.createElement('div');
this.draggedItems.className = 'dragged-items';
2026-04-07 22:48:59 +02:00
let selectedCardFromList = filesList.querySelectorAll(`div.selected > div.name-cell`);
2026-04-07 22:50:42 +02:00
if (selectedCardFromList.length === 0) {
// fallback to current element
selectedCardFromList = card.querySelectorAll('div.name-cell');
}
2026-04-07 22:48:59 +02:00
let index = 0;
const maxElements = 4;
let lastItemDiv = null;
while (index < selectedCardFromList.length && index < maxElements) {
2026-04-07 22:50:42 +02:00
const iconCell = document.createElement('div');
const icon = selectedCardFromList[index].getElementsByClassName('file-icon').item(0)?.cloneNode(true);
if (icon) {
2026-04-07 22:48:59 +02:00
iconCell.appendChild(icon);
iconCell.querySelectorAll('img')?.forEach((img) => {
img.loading = 'eager';
});
}
2025-03-19 23:28:29 +01:00
2026-04-07 22:50:42 +02:00
const nameCell = document.createElement('div');
const name = selectedCardFromList[index].getElementsByTagName('span').item(0)?.cloneNode(true);
if (name) {
nameCell.appendChild(name);
2026-03-25 18:49:38 +01:00
}
2026-04-07 22:50:42 +02:00
const div = document.createElement('div');
2026-04-07 22:48:59 +02:00
div.className = 'file-item';
div.appendChild(iconCell);
div.appendChild(nameCell);
2026-03-25 18:49:38 +01:00
2026-04-07 22:50:42 +02:00
this.draggedItems.appendChild(div);
index += 1;
lastItemDiv = div;
}
2026-03-25 18:49:38 +01:00
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`;
2026-05-07 23:40:02 +02:00
/** @type {string[]} */
const folders = [];
2026-05-07 23:40:02 +02:00
/** @type {string[]} */
const files = [];
2026-05-07 23:40:02 +02:00
/** @type {NodeListOf<HTMLDivElement>} */ (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) {
2026-04-07 22:50:42 +02:00
const badge = document.createElement('span');
2026-04-07 22:48:59 +02:00
badge.className = 'dragged-items-badge';
badge.innerText = `${selectedCardFromList.length}`;
2026-04-07 22:50:42 +02:00
this.draggedItems.appendChild(badge);
}
// if more than maxElements display the fading
if (selectedCardFromList.length > maxElements) {
2026-04-30 02:02:27 +02:00
lastItemDiv?.classList.add('fading');
}
2026-04-07 22:48:59 +02:00
2026-04-07 22:50:42 +02:00
this.dragPreview.appendChild(this.draggedItems);
e.dataTransfer.setDragImage(this.draggedItems, 0, 0);
});
2025-03-19 23:28:29 +01:00
// dragend
2026-04-07 22:50:42 +02:00
filesList.addEventListener('dragend', (_e) => {
this.dragPreview.removeChild(this.draggedItems);
document.querySelectorAll('.drop-target').forEach((el) => {
el.classList.remove('drop-target');
});
});
2025-03-19 23:28:29 +01:00
// dragover – only folders are valid drop targets
filesList.addEventListener('dragover', (e) => {
2026-04-30 02:02:27 +02:00
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');
});
2025-03-19 23:28:29 +01:00
// dragleave
filesList.addEventListener('dragleave', (e) => {
2026-04-30 02:02:27 +02:00
const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card || card.dataset.fileId) return;
card.classList.remove('drop-target');
});
2025-03-19 23:28:29 +01:00
// drop – only folders accept drops
filesList.addEventListener('drop', async (e) => {
2026-04-30 02:02:27 +02:00
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');
2026-04-30 02:02:27 +02:00
if (!e.dataTransfer) return;
const action = e.dataTransfer.dropEffect;
2026-04-07 22:50:42 +02:00
await this._dropToFolder(action, targetFolderId, e.dataTransfer);
});
},
/* ================================================================
* Favorite star helper – attaches a direct click handler to a
* star <button> so the event never bubbles to the card.
* ================================================================ */
2026-05-07 23:40:02 +02:00
/** @param {HTMLElement} el */
_bindStarClick(el) {
const star = el.querySelector('.favorite-star');
star?.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
if (!favorites) return;
// FIXME: make a function
2026-05-07 23:40:02 +02:00
const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
const isActive = star.classList.contains('active');
if (isActive) {
this.setFavoriteVisualState(itemId, itemType, false);
favorites.removeFromFavorites(itemId, itemType);
} else {
this.setFavoriteVisualState(itemId, itemType, true);
2026-05-07 23:40:02 +02:00
favorites.addToFavorites(itemId, itemName, itemType, null);
}
// Keep context-menu label in sync if available
2026-05-11 22:26:53 +02:00
contextMenus.syncFavoriteOptionLabels();
});
const shared = el.querySelector('.file-badge-shared');
shared?.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
// FIXME: make a function
2026-05-07 23:40:02 +02:00
const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
const item = /** @type {FileItem|FolderItem} */ (
/** @type {unknown} */ ({
id: itemId,
name: itemName
})
);
shareModal.open(item, /** @type {'file'|'folder'} */ (itemType));
});
},
/**
* Sync favorite visuals for a file/folder across grid and list views.
2026-05-07 23:40:02 +02:00
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isFavorite
*/
setFavoriteVisualState(itemId, itemType, isFavorite) {
2026-04-07 22:48:59 +02:00
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
const item = document.querySelector(selector);
const starBtn = item?.querySelector('.favorite-star');
// chzn
if (starBtn) {
starBtn.classList.toggle('active', !!isFavorite);
// SVG icon path (after icons.js replacement)
const svg = starBtn.querySelector('svg');
const filledPath = OxiIcons?.star;
const outlinePath = OxiIcons?.['star-outline'];
const targetPath = isFavorite ? filledPath : outlinePath;
if (svg && targetPath) {
const p = svg.querySelector('path');
2026-04-30 02:02:27 +02:00
if (p) p.setAttribute('d', String(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');
}
}
2026-04-07 22:48:59 +02:00
// toggle favorite's badge
if (item) {
const badgeFavorite = item.querySelector('.file-badge-favorite');
badgeFavorite?.classList.toggle('hidden', !isFavorite);
}
},
2026-05-07 23:40:02 +02:00
/**
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isShared
*/
setSharedVisualState(itemId, itemType, isShared) {
console.log(`setSharedVisual call for ${itemId} ${itemType} to ${isShared}`);
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
// toggle favorite's badge
const item = document.querySelector(selector);
if (item) {
const badgeShared = item.querySelector('.file-badge-shared');
badgeShared?.classList.toggle('hidden', !isShared);
}
},
/* ================================================================
* Element-creation helpers
* ================================================================ */
2026-05-07 23:40:02 +02:00
/**
* Create a list row for a folder
* @param {FolderItem} folder
*/
_createFolderItem(folder) {
const el = document.createElement('div');
el.className = 'file-item';
2026-04-07 22:48:59 +02:00
el.dataset.folderId = folder.id;
el.dataset.folderName = folder.name;
2026-04-07 22:48:59 +02:00
el.dataset.parentId = folder.parent_id || '';
if (folder.path) el.dataset.path = folder.path;
const isFav = favorites?.isFavorite(folder.id, 'folder');
2026-05-22 10:15:25 +02:00
const isShared = grants.getOutgoingGrantsFor('folder', folder.id).length > 0; //sharedView.isShared(folder.id, 'folder');
const formattedDate = formatDateTime(folder.modified_at);
el.innerHTML = `
<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>
2025-03-19 23:28:29 +01:00
<div class="name-cell">
<div class="file-icon folder-icon">
<i class="fas fa-folder"></i>
</div>
<span>${escapeHtml(folder.name)}</span>
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
2026-05-22 10:15:25 +02:00
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
2025-03-19 23:28:29 +01:00
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
<div class="type-cell">${i18n.t('files.file_types.folder')}</div>
2025-03-19 23:28:29 +01:00
<div class="size-cell">--</div>
<div class="date-cell">${formattedDate}</div>
<div class="action-cell">
<button class="favorite-star${isFav ? ' active' : ''}">
<i class="${isFav ? 'fas' : 'far'} fa-star"></i>
</button>
<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>
2025-03-19 23:28:29 +01:00
</div>
`;
if (app.currentPath !== '') {
el.setAttribute('draggable', 'true');
}
this._bindStarClick(el);
return el;
},
2025-03-19 23:28:29 +01:00
2026-05-07 23:40:02 +02:00
/**
* 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 || '';
2026-04-26 00:22:49 +02:00
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');
2026-05-22 10:15:25 +02:00
const isShared = grants.getOutgoingGrantsFor('file', file.id).length > 0;
//const isShared = sharedView.isShared(file.id, 'file');
const canThumbnail = thumbnail.canHandle(file);
2025-03-19 23:28:29 +01:00
const el = document.createElement('div');
el.className = 'file-item';
2026-04-07 22:48:59 +02:00
el.dataset.fileId = file.id;
el.dataset.fileName = file.name;
2026-04-07 22:48:59 +02:00
el.dataset.folderId = file.folder_id || '';
if (file.path) el.dataset.path = file.path;
el.setAttribute('draggable', 'true');
2025-03-19 23:28:29 +01:00
el.innerHTML = `
2026-04-07 22:50:42 +02:00
<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>
2025-03-19 23:28:29 +01:00
<div class="name-cell">
<div class="file-icon ${iconSpecialClass}">
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
2025-03-19 23:28:29 +01:00
<i class="${iconClass}"></i>
</div>
<span>${escapeHtml(file.name)}</span>
<div class="file-badge file-badge-favorite ${isFav ? '' : 'hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>
2026-05-22 10:15:25 +02:00
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
2025-03-19 23:28:29 +01:00
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
2025-03-19 23:28:29 +01:00
<div class="type-cell">${typeLabel}</div>
<div class="size-cell">${fileSize}</div>
<div class="date-cell">${formattedDate}</div>
<div class="action-cell">
<button class="favorite-star${isFav ? ' active' : ''}">
<i class="${isFav ? 'fas' : 'far'} fa-star"></i>
</button>
<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>
</div>
2025-03-19 23:28:29 +01:00
`;
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');
});
2026-04-07 22:48:59 +02:00
});
}
this._bindStarClick(el);
return el;
},
2025-03-19 23:28:29 +01:00
/* ================================================================
* Batch rendering with DocumentFragment
* ================================================================ */
resetFilesList() {
const filesList = document.getElementById('files-list');
2026-04-07 22:48:59 +02:00
const filesContainerError = document.getElementById('files-container-error');
if (!filesList) return;
2026-04-07 22:48:59 +02:00
filesList.innerHTML = `
<div class="list-header">
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
<div data-i18n="files.name">Name</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-i18n="files.owner">Owner</div>
<div data-i18n="files.type">Type</div>
<div data-i18n="files.size">Size</div>
<div data-i18n="files.modified">Modified</div>
<div></div><!-- actions -->
</div>`;
i18n.translateElement(filesList);
2026-04-07 22:48:59 +02:00
filesList.classList.remove('hidden');
filesContainerError?.classList.add('hidden');
},
showEmptyList() {
this.showError(`
<i class="fas fa-folder-open empty-state-icon"></i>
<p data-i18n="files.no_files"></p>
<p data-i18n="files.empty_hint"></p>
2026-04-07 22:48:59 +02:00
`);
},
/**
*
* @param {string} content
*
*/
showError(content) {
2026-04-07 22:48:59 +02:00
const filesContainerError = document.getElementById('files-container-error');
const filesList = document.getElementById('files-list');
if (filesContainerError) filesContainerError.innerHTML = content;
i18n.translateElement(filesContainerError);
2026-04-07 22:48:59 +02:00
filesContainerError?.classList.remove('hidden');
filesList?.classList.add('hidden');
},
/**
* Render an array of folders into both grid and list views
* using DocumentFragment for minimal reflows.
2026-04-30 02:02:27 +02:00
*
2026-05-07 23:40:02 +02:00
* @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);
}
2025-03-19 23:28:29 +01:00
this._renderFoldersToView(safeFolders);
},
2025-03-19 23:28:29 +01:00
/**
* Render an array of files into both grid and list views
* using DocumentFragment for minimal reflows.
2026-05-07 23:40:02 +02:00
* @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);
}
2025-03-19 23:28:29 +01:00
this._renderFilesToView(safeFiles);
},
2025-03-19 23:28:29 +01:00
/* ================================================================
* Single-item add (backward-compatible API for post-upload, etc.)
* ================================================================ */
2025-03-19 23:28:29 +01:00
/**
* Add a single folder to the active view.
2026-05-07 23:40:02 +02:00
* @param {FolderItem} folder
*/
addFolderToView(folder) {
if (!this._delegationReady) this.initDelegation();
2025-03-19 23:28:29 +01:00
// 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.
2026-05-07 23:40:02 +02:00
* @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;
}
2025-03-19 23:28:29 +01:00
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');
}
2025-03-19 23:28:29 +01:00
}
};
2026-02-08 22:44:42 +01:00
// --- 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.
2026-05-07 23:40:02 +02:00
* @param {HTMLDivElement} card
* @param {MouseEvent} event
2026-02-08 22:44:42 +01:00
*/
function toggleCardSelection(card, event) {
if (multiSelect) {
multiSelect.handleToggleItem(card, event);
} else {
card.classList.toggle('selected');
}
2026-02-08 22:44:42 +01:00
}
/**
* Show the context menu anchored next to a trigger element (the 3-dot button).
2026-05-07 23:40:02 +02:00
* @param {HTMLElement} triggerElement
* @param {string} menuId
2026-02-08 22:44:42 +01:00
*/
function showContextMenuAtElement(triggerElement, menuId) {
// Hide any open menus first
2026-04-07 22:50:42 +02:00
document.querySelectorAll('.context-menu').forEach((m) => {
2026-04-24 23:37:14 +02:00
m.classList.add('hidden');
2026-04-07 22:50:42 +02:00
});
2026-02-08 22:44:42 +01:00
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
}
2026-05-11 22:26:53 +02:00
contextMenus.sync();
2026-02-08 22:44:42 +01:00
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
2026-04-24 23:37:14 +02:00
menu.classList.remove('hidden');
2026-02-08 22:44:42 +01:00
}
/**
* 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;
2026-04-07 22:48:59 +02:00
let startX = 0,
startY = 0;
2026-02-08 22:44:42 +01:00
// We listen on the whole files-container (covers grid + empty space)
const container = document.querySelector('.files-container') || document.getElementById('files-list');
2026-02-08 22:44:42 +01:00
if (!container) return;
container.addEventListener('mousedown', (e) => {
2026-05-07 23:40:02 +02:00
if (!(e instanceof MouseEvent)) return;
2026-02-08 22:44:42 +01:00
// Only start if clicking empty area (not on a card, button, menu, input…)
if (e.button !== 0) return; // left click only
2026-05-07 23:40:02 +02:00
const target = /** @type {Element} */ (e.target);
2026-04-07 22:48:59 +02:00
if (
2026-05-07 23:40:02 +02:00
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')
2026-04-07 22:48:59 +02:00
)
return;
2026-02-08 22:44:42 +01:00
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 };
2026-04-07 22:48:59 +02:00
document.querySelectorAll('#files-list .file-item').forEach((card) => {
2026-02-08 22:44:42 +01:00
const cardRect = card.getBoundingClientRect();
const intersects =
2026-04-07 22:48:59 +02:00
cardRect.left < rectBounds.right && cardRect.right > rectBounds.left && cardRect.top < rectBounds.bottom && cardRect.bottom > rectBounds.top;
2026-02-08 22:44:42 +01:00
if (intersects) {
card.classList.add('selected');
2026-03-25 18:49:38 +01:00
// Sync with multiSelect module
if (multiSelect) {
2026-05-07 23:40:02 +02:00
const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.select(info.id, info.name, info.type, info.parentId);
}
2026-02-08 22:44:42 +01:00
} else {
card.classList.remove('selected');
// Deselect from multiSelect module
if (multiSelect) {
2026-05-07 23:40:02 +02:00
const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.deselect(info.id);
}
2026-02-08 22:44:42 +01:00
}
});
});
document.addEventListener('mouseup', () => {
if (!active) return;
active = false;
const hadSelection = selRect.style.display === 'block';
2026-02-08 22:44:42 +01:00
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) {
2026-05-07 23:40:02 +02:00
requestAnimationFrame(() => {});
}
2026-02-08 22:44:42 +01:00
});
}
// 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<boolean>} true if confirmed, false if cancelled
*/
2026-04-30 02:02:27 +02:00
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');
2026-02-08 22:44:42 +01:00
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 = `
<div class="confirm-dialog-content">
<div class="confirm-dialog-icon">
<i class="fas ${danger ? 'fa-exclamation-triangle' : 'fa-question-circle'}"></i>
</div>
<div class="confirm-dialog-title">${t}</div>
<div class="confirm-dialog-message">${message || ''}</div>
<div class="confirm-dialog-buttons">
<button class="btn btn-secondary confirm-dialog-cancel">${cc}</button>
<button class="btn ${danger ? 'btn-danger' : 'btn-primary'} confirm-dialog-ok">${ct}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// Force layout then show
2026-04-07 22:48:59 +02:00
requestAnimationFrame(() => {
overlay.classList.add('active');
});
2026-02-08 22:44:42 +01:00
2026-05-07 23:40:02 +02:00
/** @param {boolean} result */
2026-02-08 22:44:42 +01:00
const cleanup = (result) => {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200);
resolve(result);
};
2026-04-30 02:02:27 +02:00
overlay.querySelector('.confirm-dialog-cancel')?.addEventListener('click', () => cleanup(false));
overlay.querySelector('.confirm-dialog-ok')?.addEventListener('click', () => cleanup(true));
2026-04-07 22:48:59 +02:00
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanup(false);
});
2026-02-08 22:44:42 +01:00
});
}
export { initRubberBandSelection, showConfirmDialog, showContextMenuAtElement, toggleCardSelection, ui };