From 859f9b5f868eada136a2e9f22df898115ab9f8a3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 27 May 2026 22:51:15 +0200 Subject: [PATCH] refactor: separate roles between ui, fileView, resourceList --- static/js/app/filesView.js | 360 ++++------ static/js/app/ui.js | 645 ++---------------- static/js/components/resourceList.js | 44 ++ static/js/features/files/fileOperations.js | 4 +- static/js/features/files/search.js | 6 +- static/js/features/library/favorites.js | 2 +- static/js/model/filesModel.js | 110 +++ .../js/views/sharedWithMe/sharedWithMeView.js | 2 +- 8 files changed, 363 insertions(+), 810 deletions(-) create mode 100644 static/js/model/filesModel.js diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index aa1f96e4..747879c6 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -1,8 +1,25 @@ // @ts-check +/** + * OxiCloud – Files section view. + * + * Orchestrates the main Files section: + * - Data fetching via `filesModel` + * - Rendering via a `ResourceListComponent` instance + * - Drag-and-drop initialisation (delegated to `ui.initDragDrop`) + * + * Exports `loadFiles` (navigation & deep-link entry-point) and `addItem` + * (post-upload / post-create optimistic UI updates used by fileOperations + * and search). + */ + +import { ResourceListComponent } from '../components/resourceList.js'; import { i18n } from '../core/i18n.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; import { inlineViewer } from '../features/files/inlineViewer.js'; +import { favorites } from '../features/library/favorites.js'; +import { fetchListing, rebuildBreadCrumb } from '../model/filesModel.js'; +import { grants } from '../model/grants.js'; import { resolveHomeFolder } from './authSession.js'; import { updateHistory } from './main.js'; import { app } from './state.js'; @@ -11,263 +28,174 @@ import { uiNotifications } from './uiNotifications.js'; /** @import {FileItem, FolderItem} from '../core/types.js' */ -let isLoadingFiles = false; +/** @type {ResourceListComponent|null} */ +let _component = null; + +/** Guard against concurrent `loadFiles` calls. */ +let _loading = false; /** - * getFolder information - * @param {string} id the id of the folder - * @returns {Promise} + * Return (creating on first call) the `ResourceListComponent` bound to + * `#files-list`. The element must already be in the DOM. + * @returns {ResourceListComponent|null} */ -async function getFolder(id) { - /** @type {HeadersInit} */ - const headers = { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache' - }; +function _ensureComponent() { + const filesList = document.getElementById('files-list'); + if (!filesList) return null; - /** @type {RequestInit} */ - const requestOptions = { - headers, - credentials: 'same-origin', - cache: 'no-store' - }; + if (!_component) { + _component = new ResourceListComponent(/** @type {HTMLElement} */ (filesList), { + selectable: true, + showFavorite: true, + showOwner: true, + showShareBadge: true, + draggable: true, + showContextMenu: true, + isFavorite: (id, type) => favorites.isFavorite(id, type), + isShared: (id, type) => grants.getOutgoingGrantsFor(type, id).length > 0, + onOpen: (item) => ui.openItem(item), + onFavoriteToggle: async (item) => { + const isFile = 'mime_type' in item; + const type = isFile ? 'file' : 'folder'; + if (favorites.isFavorite(item.id, type)) { + await favorites.removeFromFavorites(item.id, type); + _component?.setFavoriteVisualState(item.id, type, false); + } else { + await favorites.addToFavorites(item.id, item.name, type, null); + _component?.setFavoriteVisualState(item.id, type, true); + } + }, + onContextMenu: (item, e) => ui.showContextMenuForItem(item, e), + onSelectionChange: (selectedItems) => { + batchToolbar._selected.clear(); + for (const sel of selectedItems) { + const isFile = 'mime_type' in sel; + batchToolbar._selected.set(sel.id, { + id: sel.id, + name: sel.name, + type: isFile ? 'file' : 'folder', + parentId: isFile ? /** @type {FileItem} */ (sel).folder_id || '' : /** @type {FolderItem} */ (sel).parent_id || '' + }); + } + batchToolbar._syncUI(); + } + }); - const folderInformations = await fetch(`/api/folders/${id}`, requestOptions); - if (folderInformations.ok) { - return folderInformations.json(); - } else { - console.warn(`Error fetching folder ${id}`); - return Promise.reject(null); + // Wire drag-and-drop on the container once the component is created. + ui.initDragDrop(/** @type {HTMLElement} */ (filesList)); } + + return _component; } /** - * Rebuild breadcrumb from selected folder (iterate up to root). + * Append a single item to the current view (post-upload / post-create + * optimistic update). No-op when the Files section is not active or the + * item is already in the list. * - * Stops traversal gracefully when a parent folder is not accessible - * (e.g. the user entered via a "Shared with me" grant whose parent - * folder they have no permission on). In that case the partial - * breadcrumb built so far is kept — the deepest reachable ancestor - * acts as the visual root, matching how Google Drive / Dropbox handle - * shared subtrees. + * Called by `fileOperations.js` and `search.js`. * - * An error on the *target folder itself* (first iteration) is still - * treated as a real error and redirects to the home folder. + * @param {FileItem|FolderItem} item */ -async function rebuildBreadCrumb() { - /** - * Store the leaf (this is the current displayed folder) - * @type {FolderItem | null} - */ - let currentFolderInfo = null; - - // rebuild full breadcrumb, - // TODO: to optimize, data may already be known / or ETAG could be interesting to reduce load - app.breadcrumbPath = []; - - /** @type {string | null} */ - let id = app.currentPath; - - // recurse from selected folder to root - while (id !== null) { - console.log(`fetching folder information for folder ${id}`); - try { - const folderInfo = await getFolder(id); - - // store the Leaf which is the current folder - if (currentFolderInfo === null) { - currentFolderInfo = folderInfo; - } - - // Add every folder to the breadcrumb, including the root (home folder). - // updateBreadcrumb() no longer auto-prepends home — it's our responsibility here. - app.breadcrumbPath.unshift({ - id: folderInfo.id, - name: folderInfo.name - }); - - // iterate to parent folder - id = folderInfo.parent_id; - } catch (_e) { - if (currentFolderInfo === null) { - // Failed on the target folder itself — real error, fall back to home. - console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`); - uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights'); - app.breadcrumbPath = []; - id = app.userHomeFolderId; - if (id) app.currentPath = id; - } else { - // Failed on a parent — hit the permission boundary of a shared subtree. - // Stop traversal; the partial breadcrumb is the best we can show. - console.log(`Stopped breadcrumb traversal at permission boundary (parent of ${currentFolderInfo.id} is not accessible)`); - break; - } - } - } - - // store informations on the current folder - app.currentFolderInfo = currentFolderInfo; +function addItem(item) { + const component = _ensureComponent(); + if (!component) return; + // Reveal the list if the empty-state is showing + ui.resetFilesList(); + component.addItem(item); } -// TODO split load() vs view() /** - * Files view loading logic - * - * @param {Object} options - * @param {boolean} [options.insertHistory] add browser history (default true) - * @param {boolean} [options.forceRefresh] force refresh of content + * Load and render the contents of `app.currentPath`, rebuilding the + * breadcrumb and updating browser history. * + * @param {Object} [options] + * @param {boolean} [options.insertHistory=true] + * @param {boolean} [options.forceRefresh=false] */ async function loadFiles(options = { insertHistory: true }) { + if (_loading) { + console.log('A file load is already in progress, ignoring request'); + return; + } + _loading = true; + + // Delay spinner so fast loads avoid the flash + const spinnerTimeout = setTimeout(() => { + ui.showError(` +
+
+ ${i18n.t('files.loading')} +
+ `); + }, 100); + try { - console.log('Starting loadFiles() - loading files...', options); - - const forceRefresh = options.forceRefresh || false; - - if (isLoadingFiles) { - console.log('A file load is already in progress, ignoring request'); - return; - } - - isLoadingFiles = true; - - // This to avoid blinking page, a better solution would be to put loading on an overlay and remove timeout - const loadingFiles = setTimeout(() => { - // display loader after few delay (will be canceled if result take less time) - ui.showError(` -
-
- ${i18n.t('files.loading')} -
- `); - }, 100); - - if (!app.userHomeFolderId) { - await resolveHomeFolder(); - } - - const timestamp = Math.floor(Date.now() / 1000); - - await rebuildBreadCrumb(); - - // request a breadcrumb paint - ui.updateBreadcrumb(); - - updateHistory(options.insertHistory || false); - - let url; + if (!app.userHomeFolderId) await resolveHomeFolder(); + // Resolve path to home folder when none is set if (!app.currentPath || app.currentPath === '') { if (app.userHomeFolderId) { - url = `/api/folders/${app.userHomeFolderId}/listing?t=${timestamp}`; app.currentPath = app.userHomeFolderId; app.breadcrumbPath = []; - ui.updateBreadcrumb(); console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`); } else { - url = `/api/folders?t=${timestamp}`; - console.warn('Emergency fallback to root folder - this should not normally happen'); + console.warn('No home folder id — this should not normally happen'); } - } else { - url = `/api/folders/${app.currentPath}/listing?t=${timestamp}`; - console.log(`Loading subfolder content: ${app.currentPath}`); } - /** @type {HeadersInit} */ - const headers = { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache' - }; + await rebuildBreadCrumb(); + ui.updateBreadcrumb(); + updateHistory(options.insertHistory ?? true); - /** @type {RequestInit} */ - const requestOptions = { - headers, - credentials: 'same-origin', - cache: 'no-store' - }; + const { folders, files } = await fetchListing(app.currentPath, { + forceRefresh: options.forceRefresh ?? false + }); - if (forceRefresh) { - url += `&force_refresh=true`; - if (requestOptions.headers) { - const headers = new Headers(requestOptions.headers); - headers.set('X-Force-Refresh', 'true'); - requestOptions.headers = headers; - } - console.log('Forcing complete refresh ignoring cache'); - } + clearTimeout(spinnerTimeout); - console.log(`Loading listing from ${url}`); - const response = await fetch(url, requestOptions); - - // not required anymore - clearTimeout(loadingFiles); - - if (response.status === 403) { - console.warn('Forbidden when loading files'); - // FIXME: i18n - ui.showError(`

Could not load files

`); - return; - } - - if (!response.ok) { - throw new Error(`Server responded with status: ${response.status}`); - } - - const listing = await response.json(); - - ui._items.clear(); + // Prepare the container (shows #files-list, hides error panel) ui.resetFilesList(); - if (batchToolbar) { - batchToolbar.clear(); - batchToolbar.init(); // this will wire buttons & select-all-checkbox - } - /** @type {FolderItem[]} */ - const folderList = Array.isArray(listing.folders) ? listing.folders : []; + const component = _ensureComponent(); + if (!component) return; - /** @type {FileItem[]} */ - const fileList = Array.isArray(listing.files) ? listing.files : []; + batchToolbar.clear(); + batchToolbar.init(); + batchToolbar.setActiveComponent(component); - if (folderList.length === 0 && fileList.length === 0) { + if (folders.length === 0 && files.length === 0) { ui.showEmptyList(); } else { - ui.renderFolders(folderList); - ui.renderFiles(fileList); - ui.resolveOwnerCells(); - - // check if a file was provided - if (app.viewFile) { - let fileFound = null; - - // lookup for the given fle - for (const file of fileList) { - if (file.id === app.viewFile) { - fileFound = file; - break; - } - } - - if (fileFound) { - console.log(`file ${app.viewFile} found, calling viewer`); - await inlineViewer.openFile(fileFound); - } else { - // remove file - console.log(`file ${app.viewFile} not found`); - app.viewFile = null; - - // correct url/history as file is not found - updateHistory(false); - } - } + component.render([...folders, ...files]); + await component.resolveOwnerCells(); } - console.log(`Loaded ${folderList.length} folders and ${fileList.length} files`); - } catch (error) { - console.error('Error loading folders:', error); - ui.showNotification('Error', 'Could not load files and folders'); + console.log(`Loaded ${folders.length} folders and ${files.length} files`); + + // Deep-link: open a specific file if requested via app.viewFile + if (app.viewFile) { + const fileFound = files.find((f) => f.id === app.viewFile) ?? null; + if (fileFound) { + console.log(`file ${app.viewFile} found, calling viewer`); + await inlineViewer.openFile(fileFound); + } else { + console.log(`file ${app.viewFile} not found`); + app.viewFile = null; + updateHistory(false); + } + } + } catch (/** @type {any} */ err) { + clearTimeout(spinnerTimeout); + if (err?.status === 403) { + ui.showError(`

${i18n.t('errors.forbidden', 'Could not load files')}

`); + } else { + console.error('Error loading folders:', err); + uiNotifications.show('Error', 'Could not load files and folders'); + } } finally { - isLoadingFiles = false; + _loading = false; } } -export { loadFiles }; +export { addItem, loadFiles }; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 27776151..d4c7af10 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -5,9 +5,6 @@ // @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 { batchToolbar } from '../features/files/batchToolbar.js'; @@ -15,11 +12,7 @@ import { contextMenus } from '../features/files/contextMenus.js'; import { fileOps } from '../features/files/fileOperations.js'; import { inlineViewer } from '../features/files/inlineViewer.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'; -import { grants } from '../model/grants.js'; -import { systemUsers } from '../model/systemUsers.js'; import { loadFiles } from './filesView.js'; import { updateHistory } from './main.js'; import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js'; @@ -424,8 +417,6 @@ const ui = { * Switch to grid view */ switchToGridView() { - this._hydrateViewIfNeeded(); - app.currentView = 'grid'; localStorage.setItem('oxicloud-view', 'grid'); @@ -436,8 +427,6 @@ const ui = { * Switch to list view */ switchToListView() { - this._hydrateViewIfNeeded(); - app.currentView = 'list'; localStorage.setItem('oxicloud-view', 'list'); @@ -459,32 +448,6 @@ const ui = { }); }, - /** - * 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} - */ - async resolveOwnerCells() { - const filesList = document.getElementById('files-list'); - const cells = /** @type {NodeListOf} */ (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')); - } - }, - /** * Update breadcrumb navigation from the breadcrumbPath array. * Renders: Home > folder1 > folder2 > ... @@ -644,22 +607,6 @@ const ui = { } }, - /* ================================================================ - * 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'; @@ -668,58 +615,6 @@ const ui = { 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 @@ -778,164 +673,34 @@ const ui = { 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. + * Attach delegated drag-and-drop listeners to a files-list container. + * Called once by `filesView.js` after the `ResourceListComponent` is created. + * Idempotent — a second call on the same element is a no-op. + * + * Handles: + * - `dragstart` / `dragend` — visual preview + dataTransfer payload + * - `dragover` / `dragleave` / `drop` — folder drop targets (delegated) + * + * @param {HTMLElement} container The `#files-list` element. */ - initDelegation() { - if (this._delegationReady) return; - const filesList = document.getElementById('files-list'); - if (!filesList) return; - this._delegationReady = true; + initDragDrop(container) { + if (container.dataset.dragDropReady) return; + container.dataset.dragDropReady = '1'; - // ── helpers ──────────────────────────────────────────────── - /** @param {HTMLDivElement} card */ + // ── helpers ──────────────────────────────────────────────────────── + /** @param {HTMLElement} 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) - }; + if (fileId) return { type: 'file', id: fileId, name: card.dataset.fileName ?? '' }; const folderId = card.dataset.folderId; - if (folderId) - return { - type: 'folder', - id: folderId, - name: card.dataset.folderName, - data: this._items.get(folderId) - }; + if (folderId) return { type: 'folder', id: folderId, name: card.dataset.folderName ?? '' }; return null; }; - /** @param {FileItem} file */ - const openFile = async (file) => this._openFile(file); - - /** @param {HTMLElement} card */ - const navigateFolder = (card) => this._navigateToFolder(card.dataset.folderId, card.dataset.folderName); - - /** - * @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) => { - // ResourceListComponent manages its own delegation when mounted here - if (filesList.dataset.managedBy) return; - 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 && batchToolbar) { - batchToolbar.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) => { - if (filesList.dataset.managedBy) return; - 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) => { + // ── dragstart ────────────────────────────────────────────────────── + container.addEventListener('dragstart', (e) => { const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card) { e.preventDefault(); @@ -947,146 +712,114 @@ const ui = { 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) + if (info.type === 'folder') e.dataTransfer.setData('application/oxicloud-folder', 'true'); 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 selectedCards = container.querySelectorAll('div.selected > div.name-cell'); + if (selectedCards.length === 0) selectedCards = card.querySelectorAll('div.name-cell'); - let index = 0; const maxElements = 4; let lastItemDiv = null; + let index = 0; - while (index < selectedCardFromList.length && index < maxElements) { + while (index < selectedCards.length && index < maxElements) { const iconCell = document.createElement('div'); - const icon = selectedCardFromList[index].getElementsByClassName('file-icon').item(0)?.cloneNode(true); + const icon = selectedCards[index].getElementsByClassName('file-icon').item(0)?.cloneNode(true); if (icon) { iconCell.appendChild(icon); - iconCell.querySelectorAll('img')?.forEach((img) => { + 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 name = selectedCards[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; + index += 1; } - 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 + let downloadUrl; + if (selectedCards.length === 1) { if (info.type === 'file') { - nameEncoded = info.name.replaceAll(/:/g, '-'); // issue is that DownloadURL is using : as separator; + nameEncoded = info.name.replaceAll(/:/g, '-'); 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, '-'); + 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); - } + /** @type {string[]} */ const folderIds = []; + /** @type {string[]} */ const fileIds = []; + /** @type {NodeListOf} */ (container.querySelectorAll('div.selected')).forEach((el) => { + const item = itemInfo(/** @type {HTMLElement} */ (el)); + if (item?.type === 'file') fileIds.push(item.id); + else if (item) folderIds.push(item.id); }); - downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${files.join(',')}&folder_ids=${folders.join(',')}`; + downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`; } - e.dataTransfer?.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); + e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); - // if more than 1 item, display the badge - if (selectedCardFromList.length > 1) { + if (selectedCards.length > 1) { const badge = document.createElement('span'); badge.className = 'dragged-items-badge'; - badge.innerText = `${selectedCardFromList.length}`; + badge.innerText = `${selectedCards.length}`; this.draggedItems.appendChild(badge); } + if (selectedCards.length > maxElements) lastItemDiv?.classList.add('fading'); - // if more than maxElements display the fading - if (selectedCardFromList.length > maxElements) { - lastItemDiv?.classList.add('fading'); - } - - this.dragPreview.appendChild(this.draggedItems); + this.dragPreview?.appendChild(this.draggedItems); e.dataTransfer.setDragImage(this.draggedItems, 0, 0); }); - // dragend - filesList.addEventListener('dragend', (_e) => { - this.dragPreview.removeChild(this.draggedItems); + // ── dragend ──────────────────────────────────────────────────────── + container.addEventListener('dragend', () => { + if (this.draggedItems && this.dragPreview?.contains(this.draggedItems)) { + 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) => { + // ── dragover — only folder cards are valid drop targets ──────────── + container.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; + if (!card || card.dataset.fileId || !card.dataset.folderId) return; e.preventDefault(); card.classList.add('drop-target'); }); - // dragleave - filesList.addEventListener('dragleave', (e) => { + // ── dragleave ────────────────────────────────────────────────────── + container.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) => { + // ── drop ─────────────────────────────────────────────────────────── + container.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; - + if (!card || card.dataset.fileId || !card.dataset.folderId) return; e.preventDefault(); card.classList.remove('drop-target'); - if (!e.dataTransfer) return; - const action = e.dataTransfer.dropEffect; - await this._dropToFolder(action, targetFolderId, e.dataTransfer); + await this._dropToFolder(e.dataTransfer.dropEffect, card.dataset.folderId, e.dataTransfer); }); }, @@ -1208,67 +941,6 @@ const ui = { } }, - /* ================================================================ - * 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'); @@ -1489,96 +1050,6 @@ const ui = { 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'); - } } }; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 557040d0..168d07fe 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -21,6 +21,8 @@ import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { thumbnail } from '../features/thumbnail.js'; +import { systemUsers } from '../model/systemUsers.js'; +import { createUserVignette } from './userVignette.js'; /** * @import {FileItem, FolderItem} from '../core/types.js' @@ -226,6 +228,48 @@ export class ResourceListComponent { this._container.classList.toggle('files-list-view', mode === 'list'); } + /** + * Return the registered item for the given id, or `undefined` if absent. + * @param {string} id + * @returns {FileItem|FolderItem|undefined} + */ + getItem(id) { + return this._items.get(id); + } + + /** + * Append a single item, skipping silently if already present (duplicate guard). + * Clears the empty-state placeholder when the first item is added. + * @param {FileItem|FolderItem} item + */ + addItem(item) { + if (this._items.has(item.id)) return; + // Also guard against stale DOM remnants not tracked in _items + const isFile = 'mime_type' in item; + const attr = isFile ? `data-file-id="${item.id}"` : `data-folder-id="${item.id}"`; + if (this._container.querySelector(`.file-item[${attr}]`)) return; + this._container.classList.remove('hidden'); + this._appendItems([item]); + } + + /** + * Asynchronously fill every un-resolved `.owner-cell` in this component's + * container with the display name for its `data-owner-id` attribute. + * Idempotent — cells already stamped with `data-owner-resolved` are skipped. + * @returns {Promise} + */ + async resolveOwnerCells() { + const cells = /** @type {NodeListOf} */ (this._container.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])')); + if (!cells.length) return; + systemUsers.prefetch(); // warm cache once (idempotent) + for (const cell of cells) { + const id = cell.dataset.ownerId; + cell.dataset.ownerResolved = '1'; + if (!id) continue; + cell.replaceChildren(createUserVignette(id, 'list')); + } + } + /** * Show or hide the owner column on all current and future items. * @param {boolean} visible diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index be9924ca..f5e2a78e 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -4,7 +4,7 @@ */ import { refreshUserData } from '../../app/authSession.js'; -import { loadFiles } from '../../app/filesView.js'; +import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; @@ -781,7 +781,7 @@ const fileOps = { // Optimistic UI: add folder card directly from server response // — no reload needed since the backend already confirmed creation. - ui.addFolderToView(folder); + filesViewAddItem(folder); ui.showNotification('Folder created', `"${name}" created successfully`); } else { diff --git a/static/js/features/files/search.js b/static/js/features/files/search.js index 417b6bbd..8db9fa5a 100644 --- a/static/js/features/files/search.js +++ b/static/js/features/files/search.js @@ -7,7 +7,7 @@ * displays the enriched results returned by the server. */ -import { loadFiles } from '../../app/filesView.js'; +import { addItem as filesViewAddItem, loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { ui } from '../../app/ui.js'; import { getAuthHeaders } from './fileOperations.js'; @@ -200,12 +200,12 @@ const search = { // Render folders (server-provided enriched data) results.folders.forEach((folder) => { - ui.addFolderToView(folder); + filesViewAddItem(folder); }); // Render files (server-provided enriched data) results.files.forEach((file) => { - ui.addFileToView(file); + filesViewAddItem(file); }); }, diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index f62f39e7..fc2629ff 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -283,7 +283,7 @@ const favorites = { pathTooltip.init(filesList); } - await ui.resolveOwnerCells(); + await this._component?.resolveOwnerCells(); } catch (error) { console.error('Error displaying favorites:', error); if (ui?.showNotification) { diff --git a/static/js/model/filesModel.js b/static/js/model/filesModel.js new file mode 100644 index 00000000..a4833ca6 --- /dev/null +++ b/static/js/model/filesModel.js @@ -0,0 +1,110 @@ +// @ts-check + +/** + * OxiCloud – Files data model. + * + * Pure data layer: all API calls for file/folder listing and breadcrumb + * resolution, with zero DOM dependency. Views import these functions and + * call them without knowing the fetch details. + */ + +import { app } from '../app/state.js'; +import { uiNotifications } from '../app/uiNotifications.js'; + +/** @import {FileItem, FolderItem} from '../core/types.js' */ + +/** @type {RequestInit} */ +const NO_CACHE = { + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache' }, + credentials: 'same-origin', + cache: 'no-store' +}; + +/** + * Fetch metadata for a single folder. + * Rejects with `null` when the server returns a non-OK response. + * @param {string} id + * @returns {Promise} + */ +async function getFolder(id) { + const response = await fetch(`/api/folders/${id}`, NO_CACHE); + if (response.ok) return response.json(); + console.warn(`Error fetching folder ${id}`); + return Promise.reject(null); +} + +/** + * Walk up the folder hierarchy to rebuild `app.breadcrumbPath`. + * + * Stops gracefully at a permission boundary (shared subtrees) — the partial + * breadcrumb built so far becomes the visual root, matching how Google Drive + * handles shared folders the user cannot traverse beyond. + * + * An error on the target folder itself is treated as a real error and falls + * back to the home folder. + * + * @returns {Promise} + */ +async function rebuildBreadCrumb() { + /** @type {FolderItem|null} */ + let currentFolderInfo = null; + app.breadcrumbPath = []; + + /** @type {string|null} */ + let id = app.currentPath; + + while (id !== null) { + try { + const folderInfo = await getFolder(id); + if (currentFolderInfo === null) currentFolderInfo = folderInfo; + app.breadcrumbPath.unshift({ id: folderInfo.id, name: folderInfo.name }); + id = folderInfo.parent_id; + } catch (_e) { + if (currentFolderInfo === null) { + console.warn(`Cannot access target folder ${app.currentPath}, falling back to home`); + uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights'); + app.breadcrumbPath = []; + id = app.userHomeFolderId; + if (id) app.currentPath = id; + } else { + console.log(`Stopped breadcrumb traversal at permission boundary (parent of ${currentFolderInfo.id} is not accessible)`); + break; + } + } + } + + app.currentFolderInfo = currentFolderInfo; +} + +/** + * Fetch the folder listing for the given folder id. + * + * @param {string} folderId + * @param {{ forceRefresh?: boolean }} [options] + * @returns {Promise<{ folders: FolderItem[], files: FileItem[] }>} + */ +async function fetchListing(folderId, options = {}) { + const timestamp = Math.floor(Date.now() / 1000); + let url = `/api/folders/${folderId}/listing?t=${timestamp}`; + + /** @type {HeadersInit} */ + const headers = { .../** @type {Record} */ (NO_CACHE.headers) }; + + if (options.forceRefresh) { + url += '&force_refresh=true'; + headers['X-Force-Refresh'] = 'true'; + } + + const response = await fetch(url, { ...NO_CACHE, headers }); + + if (response.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); + if (!response.ok) throw new Error(`Server responded with status: ${response.status}`); + + const listing = await response.json(); + return { + folders: Array.isArray(listing.folders) ? listing.folders : [], + files: Array.isArray(listing.files) ? listing.files : [] + }; +} + +export { fetchListing, getFolder, rebuildBreadCrumb }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index ef07c769..35961d6b 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -304,7 +304,7 @@ const sharedWithMeView = { if (filesList) ownerTooltip.init(filesList); // Fill the Owner column cells (idempotent: skips already-resolved rows). - await ui.resolveOwnerCells(); + await this._component?.resolveOwnerCells(); this._setLoadMoreVisible(!!this._nextCursor); } catch (err) {