refactor: separate roles between ui, fileView, resourceList
This commit is contained in:
+144
-216
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user