use of resourceList
This commit is contained in:
+125
-51
@@ -828,59 +828,10 @@ const ui = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** @param {FileItem} file */
|
/** @param {FileItem} file */
|
||||||
const openFile = async (file) => {
|
const openFile = async (file) => this._openFile(file);
|
||||||
if (!file) return;
|
|
||||||
if (recent) {
|
|
||||||
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
|
||||||
}
|
|
||||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
|
||||||
// But NOT image files - those should be previewed in the inline viewer
|
|
||||||
const ext = (file.name || '').split('.').pop().toLowerCase();
|
|
||||||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'heic', 'heif', 'avif', 'tiff'];
|
|
||||||
const isImage = file.mime_type?.startsWith('image/') || imageExts.includes(ext);
|
|
||||||
try {
|
|
||||||
if (!isImage && wopiEditor && (await wopiEditor.canEdit(file.name))) {
|
|
||||||
await wopiEditor.openInModal(file.id, file.name, 'edit');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`WOPI Editor failed, falling bck to classic view `, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isViewableFile(file) || isImage) {
|
|
||||||
if (inlineViewer) {
|
|
||||||
inlineViewer.openFile(file);
|
|
||||||
// update history
|
|
||||||
app.viewFile = file.id;
|
|
||||||
updateHistory(false);
|
|
||||||
} else {
|
|
||||||
fileOps.downloadFile(file.id, file.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fileOps.downloadFile(file.id, file.name);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @param {HTMLElement} card */
|
/** @param {HTMLElement} card */
|
||||||
const navigateFolder = (card) => {
|
const navigateFolder = (card) => this._navigateToFolder(card.dataset.folderId, card.dataset.folderName);
|
||||||
const folderId = card.dataset.folderId;
|
|
||||||
const folderName = card.dataset.folderName;
|
|
||||||
if (app.currentSection === 'favorites' || app.currentSection === 'recent') {
|
|
||||||
switchToFilesSection();
|
|
||||||
app.currentPath = folderId;
|
|
||||||
loadFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (app.currentSection === 'sharedwithme') {
|
|
||||||
// Activate Files UI (nav, breadcrumb, actions bar) without
|
|
||||||
// resetting the path — the shared folder becomes the entry point.
|
|
||||||
activateFilesUI();
|
|
||||||
}
|
|
||||||
app.breadcrumbPath.push({ id: folderId, name: folderName });
|
|
||||||
app.currentPath = folderId;
|
|
||||||
this.updateBreadcrumb();
|
|
||||||
loadFiles();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement} card
|
* @param {HTMLElement} card
|
||||||
@@ -906,6 +857,8 @@ const ui = {
|
|||||||
|
|
||||||
// ── click (open / navigate; select only via checkbox) ──
|
// ── click (open / navigate; select only via checkbox) ──
|
||||||
filesList.addEventListener('click', (e) => {
|
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'));
|
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
@@ -963,6 +916,7 @@ const ui = {
|
|||||||
// ── shared events ──────────────────────
|
// ── shared events ──────────────────────
|
||||||
|
|
||||||
filesList.addEventListener('contextmenu', (e) => {
|
filesList.addEventListener('contextmenu', (e) => {
|
||||||
|
if (filesList.dataset.managedBy) return;
|
||||||
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
|
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1136,6 +1090,124 @@ const ui = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Item open / navigate — shared by ui.js delegation and component
|
||||||
|
* callbacks so the same logic fires regardless of which view renders
|
||||||
|
* the items.
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a file: dispatch a recent-access event, try WOPI, fall back to
|
||||||
|
* inline viewer or download.
|
||||||
|
* @param {FileItem} file
|
||||||
|
*/
|
||||||
|
async _openFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
if (recent) {
|
||||||
|
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
||||||
|
}
|
||||||
|
// WOPI editor intercept: open Office documents in the WOPI editor
|
||||||
|
// But NOT image files - those should be previewed in the inline viewer
|
||||||
|
const ext = (file.name || '').split('.').pop().toLowerCase();
|
||||||
|
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'heic', 'heif', 'avif', 'tiff'];
|
||||||
|
const isImage = file.mime_type?.startsWith('image/') || imageExts.includes(ext);
|
||||||
|
try {
|
||||||
|
if (!isImage && wopiEditor && (await wopiEditor.canEdit(file.name))) {
|
||||||
|
await wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`WOPI Editor failed, falling back to classic view`, e);
|
||||||
|
}
|
||||||
|
if (this.isViewableFile(file) || isImage) {
|
||||||
|
if (inlineViewer) {
|
||||||
|
inlineViewer.openFile(file);
|
||||||
|
app.viewFile = file.id;
|
||||||
|
updateHistory(false);
|
||||||
|
} else {
|
||||||
|
fileOps.downloadFile(file.id, file.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileOps.downloadFile(file.id, file.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate into a folder, handling section transitions (SharedWithMe,
|
||||||
|
* Favorites, Recent → Files).
|
||||||
|
* @param {string|undefined} folderId
|
||||||
|
* @param {string|undefined} folderName
|
||||||
|
*/
|
||||||
|
_navigateToFolder(folderId, folderName) {
|
||||||
|
if (!folderId) return;
|
||||||
|
if (app.currentSection === 'favorites' || app.currentSection === 'recent') {
|
||||||
|
switchToFilesSection();
|
||||||
|
app.currentPath = folderId;
|
||||||
|
loadFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app.currentSection === 'sharedwithme') {
|
||||||
|
// Activate Files UI (nav, breadcrumb, actions bar) without
|
||||||
|
// resetting the path — the shared folder becomes the entry point.
|
||||||
|
activateFilesUI();
|
||||||
|
}
|
||||||
|
app.breadcrumbPath.push({ id: folderId, name: folderName || '' });
|
||||||
|
app.currentPath = folderId;
|
||||||
|
this.updateBreadcrumb();
|
||||||
|
loadFiles();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a file or navigate into a folder.
|
||||||
|
* Used as the `onOpen` callback for `ResourceListComponent`.
|
||||||
|
* @param {FileItem|FolderItem} item
|
||||||
|
*/
|
||||||
|
async openItem(item) {
|
||||||
|
if ('mime_type' in item) {
|
||||||
|
await this._openFile(/** @type {FileItem} */ (item));
|
||||||
|
} else {
|
||||||
|
const folder = /** @type {FolderItem} */ (item);
|
||||||
|
this._navigateToFolder(folder.id, folder.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the context-menu target and show the appropriate menu.
|
||||||
|
* Used as the `onContextMenu` callback for `ResourceListComponent`.
|
||||||
|
* @param {FileItem|FolderItem} item
|
||||||
|
* @param {MouseEvent} e
|
||||||
|
*/
|
||||||
|
showContextMenuForItem(item, e) {
|
||||||
|
const trigger = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-actions'));
|
||||||
|
if ('mime_type' in item) {
|
||||||
|
app.contextMenuTargetFile = /** @type {FileItem} */ (item);
|
||||||
|
if (trigger) {
|
||||||
|
showContextMenuAtElement(trigger, 'file-context-menu');
|
||||||
|
} else {
|
||||||
|
const menu = document.getElementById('file-context-menu');
|
||||||
|
if (menu) {
|
||||||
|
menu.style.left = `${e.pageX}px`;
|
||||||
|
menu.style.top = `${e.pageY}px`;
|
||||||
|
contextMenus.sync();
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.contextMenuTargetFolder = /** @type {FolderItem} */ (item);
|
||||||
|
if (trigger) {
|
||||||
|
showContextMenuAtElement(trigger, 'folder-context-menu');
|
||||||
|
} else {
|
||||||
|
const menu = document.getElementById('folder-context-menu');
|
||||||
|
if (menu) {
|
||||||
|
menu.style.left = `${e.pageX}px`;
|
||||||
|
menu.style.top = `${e.pageY}px`;
|
||||||
|
contextMenus.sync();
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Favorite star helper – attaches a direct click handler to a
|
* Favorite star helper – attaches a direct click handler to a
|
||||||
* star <button> so the event never bubbles to the card.
|
* star <button> so the event never bubbles to the card.
|
||||||
@@ -1375,6 +1447,8 @@ const ui = {
|
|||||||
const filesContainerError = document.getElementById('files-container-error');
|
const filesContainerError = document.getElementById('files-container-error');
|
||||||
|
|
||||||
if (!filesList) return;
|
if (!filesList) return;
|
||||||
|
// Let ui.js delegation handle this container again
|
||||||
|
delete filesList.dataset.managedBy;
|
||||||
|
|
||||||
filesList.innerHTML = `
|
filesList.innerHTML = `
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ export class ResourceListComponent {
|
|||||||
this._items.clear();
|
this._items.clear();
|
||||||
this._lastClickedIndex = -1;
|
this._lastClickedIndex = -1;
|
||||||
|
|
||||||
|
// Prevent ui.js global delegation from firing on this container
|
||||||
|
this._container.dataset.managedBy = 'resource-list';
|
||||||
|
|
||||||
this._appendItems(folders, files, groupFn);
|
this._appendItems(folders, files, groupFn);
|
||||||
this._wireSelectAll();
|
this._wireSelectAll();
|
||||||
}
|
}
|
||||||
@@ -138,6 +141,8 @@ export class ResourceListComponent {
|
|||||||
this._selected.clear();
|
this._selected.clear();
|
||||||
this._items.clear();
|
this._items.clear();
|
||||||
this._lastClickedIndex = -1;
|
this._lastClickedIndex = -1;
|
||||||
|
// Hand delegation back to ui.js
|
||||||
|
delete this._container.dataset.managedBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getAuthHeaders } from './fileOperations.js';
|
|||||||
/**
|
/**
|
||||||
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
|
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
|
||||||
* @import {BatchResult} from './fileOperations.js'
|
* @import {BatchResult} from './fileOperations.js'
|
||||||
|
* @import {ResourceListComponent} from '../../components/resourceList.js'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const batchToolbar = {
|
const batchToolbar = {
|
||||||
@@ -35,6 +36,23 @@ const batchToolbar = {
|
|||||||
/** Whether the selection bar is currently visible */
|
/** Whether the selection bar is currently visible */
|
||||||
_barVisible: false,
|
_barVisible: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `ResourceListComponent` currently managing the active view.
|
||||||
|
* When set, keyboard shortcuts (Ctrl+A, Escape) delegate to the component
|
||||||
|
* so its internal selection state stays consistent.
|
||||||
|
* @type {ResourceListComponent | null}
|
||||||
|
*/
|
||||||
|
_activeComponent: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register (or unregister) the component that owns the current view's
|
||||||
|
* selection state. Pass `null` when leaving a component-managed view.
|
||||||
|
* @param {ResourceListComponent | null} component
|
||||||
|
*/
|
||||||
|
setActiveComponent(component) {
|
||||||
|
this._activeComponent = component;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────
|
||||||
|
|
||||||
get count() {
|
get count() {
|
||||||
@@ -514,15 +532,23 @@ const batchToolbar = {
|
|||||||
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
||||||
|
|
||||||
const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
|
const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
|
||||||
// ctrl+a cmd+a
|
// ctrl+a / cmd+a — delegate to active component when present
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||||
if (selectAllCheckbox) selectAllCheckbox.checked = true;
|
if (this._activeComponent) {
|
||||||
this.selectAll();
|
this._activeComponent.selectAll();
|
||||||
|
} else {
|
||||||
|
if (selectAllCheckbox) selectAllCheckbox.checked = true;
|
||||||
|
this.selectAll();
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape' && this.hasSelection) {
|
if (e.key === 'Escape') {
|
||||||
this.clear();
|
if (this._activeComponent && this._activeComponent._selected.size > 0) {
|
||||||
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
this._activeComponent.clearSelection();
|
||||||
|
} else if (this.hasSelection) {
|
||||||
|
this.clear();
|
||||||
|
if (selectAllCheckbox) selectAllCheckbox.checked = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (e.key === 'Delete' && this.hasSelection) this.batchDelete();
|
if (e.key === 'Delete' && this.hasSelection) this.batchDelete();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
import { batchToolbar } from '../files/batchToolbar.js';
|
import { batchToolbar } from '../files/batchToolbar.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
import * as pathTooltip from '../pathTooltip.js';
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ const favorites = {
|
|||||||
/** Whether the initial fetch from the server has completed */
|
/** Whether the initial fetch from the server has completed */
|
||||||
_ready: false,
|
_ready: false,
|
||||||
|
|
||||||
|
/** @type {ResourceListComponent|null} */
|
||||||
|
_component: null,
|
||||||
|
|
||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
@@ -105,7 +109,7 @@ const favorites = {
|
|||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {string} type
|
* @param {string} type
|
||||||
* @param {string} _parentId
|
* @param {string | null} _parentId
|
||||||
*/
|
*/
|
||||||
async addToFavorites(id, name, type, _parentId) {
|
async addToFavorites(id, name, type, _parentId) {
|
||||||
try {
|
try {
|
||||||
@@ -178,11 +182,8 @@ const favorites = {
|
|||||||
try {
|
try {
|
||||||
await this._fetchFromServer();
|
await this._fetchFromServer();
|
||||||
|
|
||||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
ui.resetFilesList();
|
||||||
// wire buttons & select-all-checkbox as list header has changed in ui.resetFilesList()
|
|
||||||
// FIXME: this case is not easy to understand, should apply better implementation
|
|
||||||
batchToolbar.init();
|
batchToolbar.init();
|
||||||
|
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
|
|
||||||
if (this._cache.size === 0) {
|
if (this._cache.size === 0) {
|
||||||
@@ -201,11 +202,10 @@ const favorites = {
|
|||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
for (const item of this._cache.values()) {
|
for (const item of this._cache.values()) {
|
||||||
// owner_id comes from the backend JOIN (actual file/folder owner, not the favoriter)
|
// owner_id comes from the backend JOIN (actual file/folder owner)
|
||||||
if (item.item_type === 'folder') {
|
if (item.item_type === 'folder') {
|
||||||
folders.push(
|
folders.push(
|
||||||
// FIXME: better to grab the real values
|
/** @type {FolderItem} */ ({
|
||||||
/** @type {FolderItem} */ {
|
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
parent_id: item.parent_id || '',
|
parent_id: item.parent_id || '',
|
||||||
@@ -217,12 +217,11 @@ const favorites = {
|
|||||||
icon_special_class: item.icon_special_class,
|
icon_special_class: item.icon_special_class,
|
||||||
owner_id: item.owner_id ?? '',
|
owner_id: item.owner_id ?? '',
|
||||||
is_root: false
|
is_root: false
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
files.push(
|
files.push(
|
||||||
// FIXME: better to grab the real values
|
/** @type {FileItem} */ ({
|
||||||
/** @type {FileItem} */ {
|
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
name: item.item_name || item.item_id,
|
name: item.item_name || item.item_id,
|
||||||
folder_id: item.parent_id || '',
|
folder_id: item.parent_id || '',
|
||||||
@@ -237,15 +236,60 @@ const favorites = {
|
|||||||
owner_id: item.owner_id ?? '',
|
owner_id: item.owner_id ?? '',
|
||||||
created_at: item.created_at,
|
created_at: item.created_at,
|
||||||
sort_date: item.created_at
|
sort_date: item.created_at
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (folders.length) ui.renderFolders(folders);
|
|
||||||
if (files.length) ui.renderFiles(files);
|
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) pathTooltip.init(filesList);
|
if (filesList) {
|
||||||
|
if (!this._component) {
|
||||||
|
this._component = new ResourceListComponent(
|
||||||
|
/** @type {HTMLElement} */ (filesList),
|
||||||
|
{
|
||||||
|
selectable: true,
|
||||||
|
showFavorite: true,
|
||||||
|
showOwner: true,
|
||||||
|
showShareBadge: true,
|
||||||
|
draggable: false,
|
||||||
|
showContextMenu: true,
|
||||||
|
itemModifierClass: 'favorite-item',
|
||||||
|
isFavorite: (id, type) => this.isFavorite(id, type),
|
||||||
|
onOpen: (item) => ui.openItem(item),
|
||||||
|
onFavoriteToggle: async (item) => {
|
||||||
|
const isFile = 'mime_type' in item;
|
||||||
|
const type = isFile ? 'file' : 'folder';
|
||||||
|
if (this.isFavorite(item.id, type)) {
|
||||||
|
await this.removeFromFavorites(item.id, type);
|
||||||
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
|
} else {
|
||||||
|
await this.addToFavorites(item.id, item.name, type, null);
|
||||||
|
this._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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
batchToolbar.setActiveComponent(this._component);
|
||||||
|
this._component.render(folders, files);
|
||||||
|
pathTooltip.init(filesList);
|
||||||
|
}
|
||||||
|
|
||||||
await ui.resolveOwnerCells();
|
await ui.resolveOwnerCells();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
import { batchToolbar } from '../files/batchToolbar.js';
|
import { batchToolbar } from '../files/batchToolbar.js';
|
||||||
import * as pathTooltip from '../pathTooltip.js';
|
import * as pathTooltip from '../pathTooltip.js';
|
||||||
|
|
||||||
@@ -18,6 +19,9 @@ const recent = {
|
|||||||
/** Maximum items to request from the server */
|
/** Maximum items to request from the server */
|
||||||
MAX_RECENT_FILES: 20,
|
MAX_RECENT_FILES: 20,
|
||||||
|
|
||||||
|
/** @type {ResourceListComponent|null} */
|
||||||
|
_component: null,
|
||||||
|
|
||||||
// ───────────────────── helpers ─────────────────────
|
// ───────────────────── helpers ─────────────────────
|
||||||
|
|
||||||
_authHeaders() {
|
_authHeaders() {
|
||||||
@@ -97,24 +101,27 @@ const recent = {
|
|||||||
|
|
||||||
const recentItems = /** @type {RecentItem[]} */ (await response.json());
|
const recentItems = /** @type {RecentItem[]} */ (await response.json());
|
||||||
|
|
||||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
// resetFilesList injects the standard list-header with the
|
||||||
|
// Modified column label; we swap the last header cell to "Accessed".
|
||||||
|
ui.resetFilesList();
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
|
if (filesList) {
|
||||||
filesList.innerHTML = `
|
// Relabel the date column header from "Modified" → "Accessed"
|
||||||
<div class="list-header">
|
const dateHeader = /** @type {HTMLElement|null} */ (
|
||||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
[...filesList.querySelectorAll('.list-header > div')].find(
|
||||||
<div data-i18n="files.name">Name</div>
|
(el) => el.getAttribute('data-i18n') === 'files.modified'
|
||||||
<div data-i18n="files.type">Type</div>
|
)
|
||||||
<div data-i18n="files.size">Size</div>
|
);
|
||||||
<div data-i18n="recent.accessed">Accessed</div>
|
if (dateHeader) {
|
||||||
<div></div>
|
dateHeader.removeAttribute('data-i18n');
|
||||||
</div>
|
dateHeader.setAttribute('data-i18n', 'recent.accessed');
|
||||||
`;
|
dateHeader.textContent = i18n.t('recent.accessed', 'Accessed');
|
||||||
|
}
|
||||||
if (batchToolbar) {
|
|
||||||
batchToolbar.clear();
|
|
||||||
batchToolbar.init(); // this will wire buttons & select-all-checkbox
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batchToolbar.clear();
|
||||||
|
batchToolbar.init();
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
|
|
||||||
if (recentItems.length === 0) {
|
if (recentItems.length === 0) {
|
||||||
@@ -123,6 +130,7 @@ const recent = {
|
|||||||
<p>${i18n.t('recent.empty_state')}</p>
|
<p>${i18n.t('recent.empty_state')}</p>
|
||||||
<p>${i18n.t('recent.empty_hint')}</p>
|
<p>${i18n.t('recent.empty_hint')}</p>
|
||||||
`);
|
`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {FolderItem[]} */
|
/** @type {FolderItem[]} */
|
||||||
@@ -141,7 +149,7 @@ const recent = {
|
|||||||
modified_at: item.accessed_at,
|
modified_at: item.accessed_at,
|
||||||
path: item.item_path || '',
|
path: item.item_path || '',
|
||||||
category: 'folder',
|
category: 'folder',
|
||||||
created_at: item.accessed_at, //Wrong information
|
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||||
icon_class: item.icon_class,
|
icon_class: item.icon_class,
|
||||||
icon_special_class: item.icon_special_class,
|
icon_special_class: item.icon_special_class,
|
||||||
owner_id: '',
|
owner_id: '',
|
||||||
@@ -151,7 +159,6 @@ const recent = {
|
|||||||
if (item.item_mime_type === undefined || item.item_mime_type === null) {
|
if (item.item_mime_type === undefined || item.item_mime_type === null) {
|
||||||
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
|
// FIXME: this case should not be possible, is it an information badly cleaned up on server ?
|
||||||
console.warn('Broken information for RecentItem: ', item);
|
console.warn('Broken information for RecentItem: ', item);
|
||||||
//continue;
|
|
||||||
}
|
}
|
||||||
files.push({
|
files.push({
|
||||||
id: item.item_id,
|
id: item.item_id,
|
||||||
@@ -166,14 +173,49 @@ const recent = {
|
|||||||
modified_at: item.accessed_at,
|
modified_at: item.accessed_at,
|
||||||
path: item.item_path || '',
|
path: item.item_path || '',
|
||||||
owner_id: '',
|
owner_id: '',
|
||||||
created_at: item.accessed_at, //wrong information
|
created_at: item.accessed_at, // Wrong information — server only stores accessed_at
|
||||||
sort_date: item.accessed_at
|
sort_date: item.accessed_at
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (folders.length) ui.renderFolders(folders);
|
|
||||||
if (files.length) ui.renderFiles(files);
|
if (filesList) {
|
||||||
if (filesList) pathTooltip.init(filesList);
|
if (!this._component) {
|
||||||
|
this._component = new ResourceListComponent(
|
||||||
|
/** @type {HTMLElement} */ (filesList),
|
||||||
|
{
|
||||||
|
selectable: true,
|
||||||
|
showFavorite: true,
|
||||||
|
showOwner: false,
|
||||||
|
showShareBadge: false,
|
||||||
|
draggable: false,
|
||||||
|
showContextMenu: true,
|
||||||
|
itemModifierClass: 'recent-item',
|
||||||
|
dateField: 'modified_at', // mapped from accessed_at above
|
||||||
|
onOpen: (item) => ui.openItem(item),
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
batchToolbar.setActiveComponent(this._component);
|
||||||
|
this._component.render(folders, files);
|
||||||
|
pathTooltip.init(filesList);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error displaying recent files:', error);
|
console.error('Error displaying recent files:', error);
|
||||||
if (ui?.showNotification) {
|
if (ui?.showNotification) {
|
||||||
|
|||||||
@@ -5,18 +5,16 @@
|
|||||||
* current user access to, using the cursor-paginated
|
* current user access to, using the cursor-paginated
|
||||||
* `GET /api/grants/incoming/resources` endpoint.
|
* `GET /api/grants/incoming/resources` endpoint.
|
||||||
*
|
*
|
||||||
* Reuses the existing `#files-list` container and `ui.renderFolders` /
|
* Uses `ResourceListComponent` so the grid ↔ list toggle and all card
|
||||||
* `ui.renderFiles` so the grid ↔ list toggle and all card components work
|
* components work out of the box. A "Load more" button is injected below
|
||||||
* out of the box. A "Load more" button is injected below the files container
|
* the files container for cursor-based pagination.
|
||||||
* for cursor-based pagination.
|
|
||||||
*
|
|
||||||
* NOTE: the grid/list container will be extracted into a reusable component
|
|
||||||
* in a future refactor — this view is intentionally kept thin.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ui } from '../../app/ui.js';
|
import { ui } from '../../app/ui.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { ResourceListComponent } from '../../components/resourceList.js';
|
||||||
import { batchToolbar } from '../../features/files/batchToolbar.js';
|
import { batchToolbar } from '../../features/files/batchToolbar.js';
|
||||||
|
import { favorites } from '../../features/library/favorites.js';
|
||||||
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
import { ownerTooltip } from '../../features/ownerTooltip.js';
|
||||||
import { grants } from '../../model/grants.js';
|
import { grants } from '../../model/grants.js';
|
||||||
import { systemUsers } from '../../model/systemUsers.js';
|
import { systemUsers } from '../../model/systemUsers.js';
|
||||||
@@ -34,6 +32,9 @@ const sharedWithMeView = {
|
|||||||
|
|
||||||
_loading: false,
|
_loading: false,
|
||||||
|
|
||||||
|
/** @type {ResourceListComponent|null} */
|
||||||
|
_component: null,
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,11 +51,60 @@ const sharedWithMeView = {
|
|||||||
// by the time the user hovers over an item.
|
// by the time the user hovers over an item.
|
||||||
systemUsers.prefetch();
|
systemUsers.prefetch();
|
||||||
|
|
||||||
// Standard files-view setup: clear list, show container, init multiselect
|
// Standard files-view setup: clear list, show container
|
||||||
ui.resetFilesList();
|
ui.resetFilesList();
|
||||||
batchToolbar.init();
|
batchToolbar.init();
|
||||||
ui.updateBreadcrumb();
|
ui.updateBreadcrumb();
|
||||||
|
|
||||||
|
// Create (or re-use) the component bound to #files-list.
|
||||||
|
const filesList = document.getElementById('files-list');
|
||||||
|
if (filesList) {
|
||||||
|
if (!this._component) {
|
||||||
|
this._component = new ResourceListComponent(
|
||||||
|
/** @type {HTMLElement} */ (filesList),
|
||||||
|
{
|
||||||
|
selectable: true,
|
||||||
|
showFavorite: true,
|
||||||
|
showOwner: true,
|
||||||
|
showShareBadge: false,
|
||||||
|
draggable: false,
|
||||||
|
showContextMenu: true,
|
||||||
|
isFavorite: (id, type) => favorites.isFavorite(id, type),
|
||||||
|
isShared: () => false,
|
||||||
|
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);
|
||||||
|
this._component?.setFavoriteVisualState(item.id, type, false);
|
||||||
|
} else {
|
||||||
|
await favorites.addToFavorites(item.id, item.name, type, null);
|
||||||
|
this._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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
batchToolbar.setActiveComponent(this._component);
|
||||||
|
}
|
||||||
|
|
||||||
await this._loadPage();
|
await this._loadPage();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -66,6 +116,8 @@ const sharedWithMeView = {
|
|||||||
const w = document.getElementById(LOAD_MORE_ID);
|
const w = document.getElementById(LOAD_MORE_ID);
|
||||||
if (w) w.classList.add('hidden');
|
if (w) w.classList.add('hidden');
|
||||||
|
|
||||||
|
batchToolbar.setActiveComponent(null);
|
||||||
|
|
||||||
const filesList = document.getElementById('files-list');
|
const filesList = document.getElementById('files-list');
|
||||||
if (filesList) ownerTooltip.destroy(filesList);
|
if (filesList) ownerTooltip.destroy(filesList);
|
||||||
},
|
},
|
||||||
@@ -74,13 +126,17 @@ const sharedWithMeView = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch one page, map items → FileItem / FolderItem, render them, then
|
* Fetch one page, map items → FileItem / FolderItem, render them, then
|
||||||
* stamp `data-owner-id` and wire the owner tooltip.
|
* wire the owner tooltip.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async _loadPage() {
|
async _loadPage() {
|
||||||
if (this._loading) return;
|
if (this._loading) return;
|
||||||
this._loading = true;
|
this._loading = true;
|
||||||
|
|
||||||
|
// Remember whether this is a fresh first-page load (cursor was null on
|
||||||
|
// entry) so we know whether to replace or append items.
|
||||||
|
const isFirstPage = this._nextCursor === null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await grants.fetchSharedWithMe({
|
const data = await grants.fetchSharedWithMe({
|
||||||
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
resourceTypes: /** @type {ResourceTypeEnum[]} */ (['file', 'folder']),
|
||||||
@@ -90,7 +146,7 @@ const sharedWithMeView = {
|
|||||||
|
|
||||||
this._nextCursor = data.next_cursor ?? null;
|
this._nextCursor = data.next_cursor ?? null;
|
||||||
|
|
||||||
if (data.items.length === 0 && !this._nextCursor) {
|
if (data.items.length === 0 && isFirstPage) {
|
||||||
// First page came back empty
|
// First page came back empty
|
||||||
ui.showError(`
|
ui.showError(`
|
||||||
<i class="fas fa-share-alt empty-state-icon"></i>
|
<i class="fas fa-share-alt empty-state-icon"></i>
|
||||||
@@ -101,17 +157,18 @@ const sharedWithMeView = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { folders, files, ownerMap } = this._mapItems(data.items);
|
const { folders, files } = this._mapItems(data.items);
|
||||||
if (folders.length) ui.renderFolders(folders);
|
|
||||||
if (files.length) ui.renderFiles(files);
|
|
||||||
|
|
||||||
// Stamp data-owner-id on the freshly-rendered cards and attach tooltips.
|
if (isFirstPage) {
|
||||||
const filesList = document.getElementById('files-list');
|
this._component?.render(folders, files);
|
||||||
if (filesList) {
|
} else {
|
||||||
this._stampOwnerIds(filesList, ownerMap);
|
this._component?.append(folders, files);
|
||||||
ownerTooltip.init(filesList);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire owner tooltips after items are in the DOM
|
||||||
|
const filesList = document.getElementById('files-list');
|
||||||
|
if (filesList) ownerTooltip.init(filesList);
|
||||||
|
|
||||||
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
// Fill the Owner column cells (idempotent: skips already-resolved rows).
|
||||||
await ui.resolveOwnerCells();
|
await ui.resolveOwnerCells();
|
||||||
|
|
||||||
@@ -128,16 +185,12 @@ const sharedWithMeView = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map `SharedWithMeItem[]` to separate arrays for rendering plus an
|
* Map `SharedWithMeItem[]` to separate arrays for rendering.
|
||||||
* `ownerMap` (itemId → grantedBy userId) used to stamp `data-owner-id`
|
* Sets `owner_id` to `item.granted_by` so the component stamps
|
||||||
* after the cards are in the DOM.
|
* `data-owner-id` with the granter's user ID automatically.
|
||||||
*
|
|
||||||
* The backend already includes all display fields (`icon_class`,
|
|
||||||
* `icon_special_class`, `category`, `size_formatted`) inside the nested
|
|
||||||
* `file` / `folder` objects, so no client-side enrichment is needed.
|
|
||||||
*
|
*
|
||||||
* @param {SharedWithMeItem[]} items
|
* @param {SharedWithMeItem[]} items
|
||||||
* @returns {{ folders: FolderItem[], files: FileItem[], ownerMap: Map<string,string> }}
|
* @returns {{ folders: FolderItem[], files: FileItem[] }}
|
||||||
*/
|
*/
|
||||||
_mapItems(items) {
|
_mapItems(items) {
|
||||||
/** @type {FolderItem[]} */
|
/** @type {FolderItem[]} */
|
||||||
@@ -146,9 +199,6 @@ const sharedWithMeView = {
|
|||||||
/** @type {FileItem[]} */
|
/** @type {FileItem[]} */
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
/** @type {Map<string, string>} itemId → grantedBy userId */
|
|
||||||
const ownerMap = new Map();
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.resource_type === 'folder') {
|
if (item.resource_type === 'folder') {
|
||||||
const f = /** @type {FolderItem} */ (item.resource);
|
const f = /** @type {FolderItem} */ (item.resource);
|
||||||
@@ -158,7 +208,9 @@ const sharedWithMeView = {
|
|||||||
name: f.name,
|
name: f.name,
|
||||||
path: f.path ?? '',
|
path: f.path ?? '',
|
||||||
parent_id: f.parent_id ?? '',
|
parent_id: f.parent_id ?? '',
|
||||||
owner_id: f.owner_id ?? '',
|
// Use granted_by as owner_id so the component populates
|
||||||
|
// data-owner-id with the sharing user's ID.
|
||||||
|
owner_id: item.granted_by,
|
||||||
is_root: f.is_root ?? false,
|
is_root: f.is_root ?? false,
|
||||||
created_at: f.created_at,
|
created_at: f.created_at,
|
||||||
modified_at: f.modified_at,
|
modified_at: f.modified_at,
|
||||||
@@ -167,7 +219,6 @@ const sharedWithMeView = {
|
|||||||
category: 'folder'
|
category: 'folder'
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
ownerMap.set(f.id, item.granted_by);
|
|
||||||
} else if (item.resource_type === 'file') {
|
} else if (item.resource_type === 'file') {
|
||||||
const f = /** @type {FileItem} */ (item.resource);
|
const f = /** @type {FileItem} */ (item.resource);
|
||||||
files.push(
|
files.push(
|
||||||
@@ -176,7 +227,9 @@ const sharedWithMeView = {
|
|||||||
name: f.name,
|
name: f.name,
|
||||||
path: f.path ?? '',
|
path: f.path ?? '',
|
||||||
folder_id: f.folder_id ?? '',
|
folder_id: f.folder_id ?? '',
|
||||||
owner_id: f.owner_id ?? '',
|
// Use granted_by as owner_id so the component populates
|
||||||
|
// data-owner-id with the sharing user's ID.
|
||||||
|
owner_id: item.granted_by,
|
||||||
mime_type: f.mime_type,
|
mime_type: f.mime_type,
|
||||||
size: f.size,
|
size: f.size,
|
||||||
size_formatted: f.size_formatted,
|
size_formatted: f.size_formatted,
|
||||||
@@ -188,27 +241,10 @@ const sharedWithMeView = {
|
|||||||
category: f.category
|
category: f.category
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
ownerMap.set(f.id, item.granted_by);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { folders, files, ownerMap };
|
return { folders, files };
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk `ownerMap` and set `data-owner-id` on matching `.file-item` cards
|
|
||||||
* inside `container`. Must be called after `renderFolders`/`renderFiles`.
|
|
||||||
*
|
|
||||||
* @param {HTMLElement} container
|
|
||||||
* @param {Map<string,string>} ownerMap itemId → grantedBy userId
|
|
||||||
*/
|
|
||||||
_stampOwnerIds(container, ownerMap) {
|
|
||||||
for (const [itemId, ownerId] of ownerMap) {
|
|
||||||
const el = container.querySelector(`[data-folder-id="${itemId}"], [data-file-id="${itemId}"]`);
|
|
||||||
if (el instanceof HTMLElement) {
|
|
||||||
el.dataset.ownerId = ownerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── "Load more" button ────────────────────────────────────────────────────
|
// ── "Load more" button ────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user