refactor: separate roles between ui, fileView, resourceList

This commit is contained in:
Edouard Vanbelle
2026-05-27 22:51:15 +02:00
parent 5afb30ebfd
commit 859f9b5f86
8 changed files with 363 additions and 810 deletions
+144 -216
View File
@@ -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<FolderItem>}
* 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(`
<div class="files-loading-spinner">
<div class="spinner"></div>
<span>${i18n.t('files.loading')}</span>
</div>
`);
}, 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(`
<div class="files-loading-spinner">
<div class="spinner"></div>
<span>${i18n.t('files.loading')}</span>
</div>
`);
}, 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(`<p>Could not load files</p>`);
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(`<p>${i18n.t('errors.forbidden', 'Could not load files')}</p>`);
} 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 };
+58 -587
View File
@@ -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<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'));
}
},
/**
* 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<string, FolderItem | FileItem>} 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<HTMLDivElement>} */ (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<HTMLDivElement>} */ (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 <button> so the event never bubbles to the card.
* ================================================================ */
/** @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
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);
favorites.addToFavorites(itemId, itemName, itemType, null);
}
// Keep context-menu label in sync if available
contextMenus.syncFavoriteOptionLabels();
});
const shared = el.querySelector('.file-badge-shared');
shared?.addEventListener('click', (e) => {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
// FIXME: make a function
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.
* @param {string} itemId
@@ -1331,117 +1003,6 @@ const ui = {
* Element-creation helpers
* ================================================================ */
/**
* Create a list row for a folder
* @param {FolderItem} folder
*/
_createFolderItem(folder) {
const el = document.createElement('div');
el.className = 'file-item';
el.dataset.folderId = folder.id;
el.dataset.folderName = folder.name;
el.dataset.parentId = folder.parent_id || '';
if (folder.path) el.dataset.path = folder.path;
const isFav = favorites?.isFavorite(folder.id, 'folder');
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>
<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>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
</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>
<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>
</div>
`;
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 = `
<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>
<div class="name-cell">
<div class="file-icon ${iconSpecialClass}">
${canThumbnail ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
<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>
<div class="file-badge file-badge-shared ${isShared ? '' : 'hidden'}"><i class="fas fa-oxiexport"></i></div>
</div>
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
<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>
`;
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');
}
}
};
+44
View File
@@ -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<void>}
*/
async resolveOwnerCells() {
const cells = /** @type {NodeListOf<HTMLElement>} */ (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
+2 -2
View File
@@ -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 {
+3 -3
View File
@@ -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);
});
},
+1 -1
View File
@@ -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) {
+110
View File
@@ -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<FolderItem>}
*/
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<void>}
*/
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<string,string>} */ (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 };
@@ -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) {