2026-05-26 19:51:40 +02:00
|
|
|
/**
|
|
|
|
|
* ResourceListComponent — generic grid / list renderer for files and folders.
|
|
|
|
|
*
|
|
|
|
|
* Each view that shows a list of resources (SharedWithMe, Favorites, Recent,
|
|
|
|
|
* and the main file manager) creates its own component instance with a config
|
|
|
|
|
* that enables only the features the view needs.
|
|
|
|
|
*
|
|
|
|
|
* The component is responsible for:
|
|
|
|
|
* - Creating .file-item DOM nodes (folders first, then files)
|
|
|
|
|
* - Injecting optional swimlane dividers via a `groupFn`
|
|
|
|
|
* - Scoped event delegation (one listener per instance, never global)
|
|
|
|
|
* - Reporting events back to the view through callbacks
|
|
|
|
|
*
|
|
|
|
|
* The component does NOT own: context menus, multi-select toolbar, navigation
|
|
|
|
|
* state, or thumbnail generation queues — those remain in the calling module
|
|
|
|
|
* and are reached through the config callbacks.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// @ts-check
|
|
|
|
|
|
|
|
|
|
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
|
|
|
|
import { i18n } from '../core/i18n.js';
|
|
|
|
|
import { thumbnail } from '../features/thumbnail.js';
|
2026-05-27 22:51:15 +02:00
|
|
|
import { systemUsers } from '../model/systemUsers.js';
|
|
|
|
|
import { createUserVignette } from './userVignette.js';
|
2026-05-26 19:51:40 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @import {FileItem, FolderItem} from '../core/types.js'
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @typedef {Object} ResourceListConfig
|
|
|
|
|
*
|
|
|
|
|
* Feature flags
|
|
|
|
|
* @property {boolean} [selectable=true] - Show per-item checkboxes and enable selection.
|
|
|
|
|
* @property {boolean} [showFavorite=true] - Show the favorite-star button on each item.
|
|
|
|
|
* @property {boolean} [showOwner=false] - Show the owner column initially.
|
|
|
|
|
* @property {boolean} [showShareBadge=true] - Show the shared-resource badge on items.
|
|
|
|
|
* @property {boolean} [draggable=false] - Mark items as draggable (HTML attribute).
|
|
|
|
|
* @property {boolean} [showContextMenu=true] - Enable the three-dots button and right-click menu.
|
|
|
|
|
*
|
|
|
|
|
* Appearance
|
|
|
|
|
* @property {string} [itemModifierClass] - Extra CSS class applied to every .file-item
|
|
|
|
|
* (e.g. 'favorite-item', 'recent-item').
|
|
|
|
|
* @property {string} [dateField='modified_at'] - Which date field to display in the date column.
|
|
|
|
|
* @property {string} [dateLabel] - Column header label for the date column (i18n key).
|
|
|
|
|
*
|
|
|
|
|
* State providers (called at item-creation time)
|
|
|
|
|
* @property {(id: string, type: 'file'|'folder') => boolean} [isFavorite]
|
|
|
|
|
* @property {(id: string, type: 'file'|'folder') => boolean} [isShared]
|
|
|
|
|
*
|
|
|
|
|
* Callbacks (all optional; the component silently skips missing ones)
|
|
|
|
|
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onOpen]
|
|
|
|
|
* Called when the user clicks an item (not a button inside it).
|
|
|
|
|
* @property {(item: FileItem|FolderItem) => Promise<void>} [onFavoriteToggle]
|
|
|
|
|
* Called when the user clicks the favorite-star button.
|
|
|
|
|
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu]
|
|
|
|
|
* Called for the three-dots button click, right-click, and shared-badge click.
|
|
|
|
|
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange]
|
|
|
|
|
* Called whenever the selection set changes.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export class ResourceListComponent {
|
|
|
|
|
/**
|
|
|
|
|
* @param {HTMLElement} container - The element that will contain .file-item nodes.
|
|
|
|
|
* @param {ResourceListConfig} config
|
|
|
|
|
*/
|
|
|
|
|
constructor(container, config) {
|
|
|
|
|
this._container = container;
|
|
|
|
|
|
|
|
|
|
/** @type {Required<Pick<ResourceListConfig,'selectable'|'showFavorite'|'showOwner'|'showShareBadge'|'draggable'|'showContextMenu'|'dateField'>> & ResourceListConfig} */
|
|
|
|
|
this._cfg = {
|
|
|
|
|
selectable: true,
|
|
|
|
|
showFavorite: true,
|
|
|
|
|
showOwner: false,
|
|
|
|
|
showShareBadge: true,
|
|
|
|
|
draggable: false,
|
|
|
|
|
showContextMenu: true,
|
|
|
|
|
dateField: 'modified_at',
|
|
|
|
|
...config
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Items registered with this instance, keyed by id. */
|
|
|
|
|
/** @type {Map<string, FileItem|FolderItem>} */
|
|
|
|
|
this._items = new Map();
|
|
|
|
|
|
|
|
|
|
/** IDs of currently selected items. */
|
|
|
|
|
/** @type {Set<string>} */
|
|
|
|
|
this._selected = new Set();
|
|
|
|
|
|
2026-05-26 22:59:43 +02:00
|
|
|
/** Index of the last clicked item — used for shift-click range selection. */
|
|
|
|
|
this._lastClickedIndex = -1;
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
/**
|
|
|
|
|
* The grouping key of the last rendered item — persisted across
|
|
|
|
|
* `append()` calls so load-more pages don't insert a redundant header
|
|
|
|
|
* when the first item of the new page shares a group with the last
|
|
|
|
|
* item of the previous page.
|
|
|
|
|
* `undefined` means no items have been rendered yet (reset in `render()`).
|
|
|
|
|
* @type {string|null|undefined}
|
|
|
|
|
*/
|
|
|
|
|
this._lastGroupKey = undefined;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The live DOM group wrapper of the last rendered swimlane — persisted
|
|
|
|
|
* across `append()` calls so load-more items that continue the same
|
|
|
|
|
* group are appended into the existing card rather than starting a new one.
|
|
|
|
|
* @type {HTMLElement|null}
|
|
|
|
|
*/
|
|
|
|
|
this._lastGroupEl = null;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Optional label-resolver stored between `render()` and `append()` calls.
|
|
|
|
|
* @type {((key: string) => string) | undefined}
|
|
|
|
|
*/
|
|
|
|
|
this._groupLabelFn = undefined;
|
|
|
|
|
|
2026-05-28 12:07:56 +02:00
|
|
|
/**
|
|
|
|
|
* Optional node-builder stored between `render()` and `append()` calls.
|
|
|
|
|
* When set, the swimlane header renders a DOM node instead of plain text
|
|
|
|
|
* (e.g. a user vignette for the "owner" group-by dimension).
|
|
|
|
|
* @type {((key: string) => HTMLElement) | undefined}
|
|
|
|
|
*/
|
|
|
|
|
this._headerNodeFn = undefined;
|
|
|
|
|
|
2026-05-26 19:51:40 +02:00
|
|
|
this._ownerVisible = this._cfg.showOwner;
|
|
|
|
|
|
|
|
|
|
this._initDelegation();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Public API ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Replace the current item list. Preserves an existing `.list-header`
|
|
|
|
|
* at the start of the container.
|
|
|
|
|
*
|
2026-05-27 00:32:10 +02:00
|
|
|
* Items are rendered **in the order supplied** — do not pre-sort or
|
|
|
|
|
* split them into folders/files; the caller (or server) owns ordering.
|
|
|
|
|
* Folders vs. files are distinguished at render time by whether the item
|
|
|
|
|
* has a `mime_type` property.
|
|
|
|
|
*
|
|
|
|
|
* @param {Array<FileItem|FolderItem>} items
|
2026-05-26 19:51:40 +02:00
|
|
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
|
|
|
|
* When provided, a swimlane divider is injected whenever the returned
|
2026-05-27 00:32:10 +02:00
|
|
|
* key changes. Return `null` to suppress the divider for that item.
|
|
|
|
|
* @param {((key: string) => string)=} groupLabelFn
|
|
|
|
|
* Optional: converts the raw grouping key to a human-readable header
|
|
|
|
|
* label. When omitted the key itself is used.
|
2026-05-28 12:07:56 +02:00
|
|
|
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
|
|
|
|
* Optional: builds a rich DOM node for the swimlane header (e.g. a user
|
|
|
|
|
* vignette for the "owner" group-by). When provided, `groupLabelFn` is
|
|
|
|
|
* ignored for the header and the returned node is appended instead.
|
2026-05-26 19:51:40 +02:00
|
|
|
*/
|
2026-05-28 12:07:56 +02:00
|
|
|
render(items, groupFn, groupLabelFn, headerNodeFn) {
|
2026-05-26 19:51:40 +02:00
|
|
|
const header = this._container.querySelector('.list-header');
|
|
|
|
|
this._container.innerHTML = '';
|
|
|
|
|
if (header) this._container.appendChild(header);
|
|
|
|
|
|
|
|
|
|
this._selected.clear();
|
|
|
|
|
this._items.clear();
|
2026-05-26 22:59:43 +02:00
|
|
|
this._lastClickedIndex = -1;
|
2026-05-27 00:32:10 +02:00
|
|
|
// Reset group tracking for the fresh render
|
|
|
|
|
this._lastGroupKey = undefined;
|
|
|
|
|
this._lastGroupEl = null;
|
|
|
|
|
this._groupLabelFn = groupLabelFn;
|
2026-05-28 12:07:56 +02:00
|
|
|
this._headerNodeFn = headerNodeFn;
|
2026-05-26 19:51:40 +02:00
|
|
|
|
2026-05-26 23:21:58 +02:00
|
|
|
// Prevent ui.js global delegation from firing on this container
|
|
|
|
|
this._container.dataset.managedBy = 'resource-list';
|
|
|
|
|
|
2026-05-28 12:07:56 +02:00
|
|
|
this._appendItems(items, groupFn, groupLabelFn, headerNodeFn);
|
2026-05-26 22:59:43 +02:00
|
|
|
this._wireSelectAll();
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Append additional items without clearing the existing ones (load-more).
|
2026-05-27 00:32:10 +02:00
|
|
|
* Continues swimlane grouping from the last item of the previous page —
|
|
|
|
|
* no redundant header is inserted when the key is unchanged.
|
2026-05-26 19:51:40 +02:00
|
|
|
*
|
2026-05-27 00:32:10 +02:00
|
|
|
* @param {Array<FileItem|FolderItem>} items
|
2026-05-26 19:51:40 +02:00
|
|
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
2026-05-27 00:32:10 +02:00
|
|
|
* @param {((key: string) => string)=} groupLabelFn
|
2026-05-28 12:07:56 +02:00
|
|
|
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
2026-05-26 19:51:40 +02:00
|
|
|
*/
|
2026-05-28 12:07:56 +02:00
|
|
|
append(items, groupFn, groupLabelFn, headerNodeFn) {
|
|
|
|
|
this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn);
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Remove all items (but keep `.list-header` if present). */
|
|
|
|
|
clear() {
|
|
|
|
|
const header = this._container.querySelector('.list-header');
|
|
|
|
|
this._container.innerHTML = '';
|
|
|
|
|
if (header) this._container.appendChild(header);
|
|
|
|
|
this._selected.clear();
|
|
|
|
|
this._items.clear();
|
2026-05-26 22:59:43 +02:00
|
|
|
this._lastClickedIndex = -1;
|
2026-05-27 00:32:10 +02:00
|
|
|
this._lastGroupKey = undefined;
|
|
|
|
|
this._lastGroupEl = null;
|
2026-05-26 23:21:58 +02:00
|
|
|
// Hand delegation back to ui.js
|
|
|
|
|
delete this._container.dataset.managedBy;
|
2026-05-26 22:59:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Deselect all items without removing them from the DOM.
|
|
|
|
|
* Used by the batch toolbar after an operation completes.
|
|
|
|
|
*/
|
|
|
|
|
clearSelection() {
|
|
|
|
|
this._selected.clear();
|
|
|
|
|
this._lastClickedIndex = -1;
|
|
|
|
|
this._container.querySelectorAll('.file-item.selected').forEach((card) => {
|
|
|
|
|
card.classList.remove('selected');
|
|
|
|
|
const cb = /** @type {HTMLInputElement | null} */ (card.querySelector('.item-checkbox'));
|
|
|
|
|
if (cb) cb.checked = false;
|
|
|
|
|
});
|
|
|
|
|
this._syncSelectAllCheckbox();
|
|
|
|
|
this._cfg.onSelectionChange?.([]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Select all visible items in the container.
|
|
|
|
|
*/
|
|
|
|
|
selectAll() {
|
|
|
|
|
this._container.querySelectorAll('.file-item').forEach((card) => {
|
|
|
|
|
const el = /** @type {HTMLElement} */ (card);
|
|
|
|
|
const id = el.dataset.fileId || el.dataset.folderId || '';
|
|
|
|
|
if (!id) return;
|
|
|
|
|
el.classList.add('selected');
|
|
|
|
|
const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox'));
|
|
|
|
|
if (cb) cb.checked = true;
|
|
|
|
|
this._selected.add(id);
|
|
|
|
|
});
|
|
|
|
|
this._syncSelectAllCheckbox();
|
|
|
|
|
this._notifySelectionChange();
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Switch between grid and list rendering mode.
|
|
|
|
|
* @param {'grid'|'list'} mode
|
|
|
|
|
*/
|
|
|
|
|
setViewMode(mode) {
|
|
|
|
|
this._container.classList.toggle('files-grid-view', mode === 'grid');
|
|
|
|
|
this._container.classList.toggle('files-list-view', mode === 'list');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 22:51:15 +02:00
|
|
|
/**
|
|
|
|
|
* 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'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 19:51:40 +02:00
|
|
|
/**
|
|
|
|
|
* Show or hide the owner column on all current and future items.
|
|
|
|
|
* @param {boolean} visible
|
|
|
|
|
*/
|
|
|
|
|
setOwnerVisible(visible) {
|
|
|
|
|
this._ownerVisible = visible;
|
|
|
|
|
this._container.querySelectorAll('.owner-cell').forEach((cell) => {
|
|
|
|
|
cell.classList.toggle('hidden', !visible);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update the favorite-star visual on a specific item without re-rendering.
|
|
|
|
|
* @param {string} id
|
|
|
|
|
* @param {'file'|'folder'} type
|
|
|
|
|
* @param {boolean} isFavorite
|
|
|
|
|
*/
|
|
|
|
|
setFavoriteVisualState(id, type, isFavorite) {
|
|
|
|
|
const selector = type === 'folder' ? `.file-item[data-folder-id="${id}"]` : `.file-item[data-file-id="${id}"]`;
|
|
|
|
|
const item = this._container.querySelector(selector);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
|
|
|
|
|
const star = item.querySelector('.favorite-star');
|
|
|
|
|
if (star) {
|
|
|
|
|
star.classList.toggle('active', isFavorite);
|
|
|
|
|
const i = star.querySelector('i');
|
|
|
|
|
if (i) {
|
|
|
|
|
i.classList.toggle('fas', isFavorite);
|
|
|
|
|
i.classList.toggle('far', !isFavorite);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const badge = item.querySelector('.file-badge-favorite');
|
|
|
|
|
badge?.classList.toggle('hidden', !isFavorite);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update the shared-badge visual on a specific item without re-rendering.
|
|
|
|
|
* @param {string} id
|
|
|
|
|
* @param {'file'|'folder'} type
|
|
|
|
|
* @param {boolean} isShared
|
|
|
|
|
*/
|
|
|
|
|
setSharedVisualState(id, type, isShared) {
|
|
|
|
|
const selector = type === 'folder' ? `.file-item[data-folder-id="${id}"]` : `.file-item[data-file-id="${id}"]`;
|
|
|
|
|
const item = this._container.querySelector(selector);
|
|
|
|
|
if (!item) return;
|
|
|
|
|
item.querySelector('.file-badge-shared')?.classList.toggle('hidden', !isShared);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Private helpers ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-27 00:32:10 +02:00
|
|
|
* Internal: append items to the container in the order supplied.
|
|
|
|
|
* Files vs. folders are distinguished by presence of `mime_type`.
|
|
|
|
|
*
|
|
|
|
|
* @param {Array<FileItem|FolderItem>} items
|
2026-05-26 19:51:40 +02:00
|
|
|
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
2026-05-27 00:32:10 +02:00
|
|
|
* @param {((key: string) => string)=} groupLabelFn
|
2026-05-28 12:07:56 +02:00
|
|
|
* @param {((key: string) => HTMLElement)=} headerNodeFn
|
2026-05-26 19:51:40 +02:00
|
|
|
*/
|
2026-05-28 12:07:56 +02:00
|
|
|
_appendItems(items, groupFn, groupLabelFn, headerNodeFn) {
|
2026-05-26 19:51:40 +02:00
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
// Start from the persisted key so load-more pages continue seamlessly.
|
|
|
|
|
let lastGroupKey = this._lastGroupKey;
|
|
|
|
|
|
|
|
|
|
// liveGroup: existing DOM group element from the previous page; items
|
|
|
|
|
// that continue its group are appended directly into it (not via fragment).
|
|
|
|
|
// fragmentGroup: the group wrapper currently being built in the fragment.
|
|
|
|
|
let liveGroup = groupFn ? this._lastGroupEl : null;
|
|
|
|
|
let fragmentGroup = /** @type {HTMLElement|null} */ (null);
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
this._items.set(item.id, item);
|
|
|
|
|
|
2026-05-26 19:51:40 +02:00
|
|
|
if (groupFn) {
|
2026-05-27 00:32:10 +02:00
|
|
|
const key = groupFn(item);
|
2026-05-26 19:51:40 +02:00
|
|
|
if (key !== lastGroupKey) {
|
|
|
|
|
lastGroupKey = key;
|
2026-05-27 00:32:10 +02:00
|
|
|
liveGroup = null; // stop extending the previous page's group
|
|
|
|
|
fragmentGroup = null;
|
|
|
|
|
|
|
|
|
|
if (key !== null) {
|
|
|
|
|
fragmentGroup = document.createElement('div');
|
|
|
|
|
fragmentGroup.className = 'resource-list__swimlane-group';
|
2026-05-28 12:07:56 +02:00
|
|
|
fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn));
|
2026-05-27 00:32:10 +02:00
|
|
|
fragment.appendChild(fragmentGroup);
|
|
|
|
|
}
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 00:32:10 +02:00
|
|
|
// Dispatch to the correct renderer: files have mime_type, folders do not.
|
|
|
|
|
const isFile = 'mime_type' in item;
|
|
|
|
|
const itemEl = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item));
|
|
|
|
|
|
|
|
|
|
// Priority: live DOM group (load-more continuation) > current fragment group > bare container
|
|
|
|
|
const target = liveGroup ?? fragmentGroup;
|
|
|
|
|
if (target) {
|
|
|
|
|
target.appendChild(itemEl);
|
|
|
|
|
} else {
|
|
|
|
|
fragment.appendChild(itemEl);
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._container.appendChild(fragment);
|
2026-05-27 00:32:10 +02:00
|
|
|
// Persist for the next append() call (load-more continuity).
|
|
|
|
|
this._lastGroupKey = lastGroupKey;
|
|
|
|
|
// Track the last group element (live or freshly added) for the next page.
|
|
|
|
|
this._lastGroupEl = fragmentGroup ?? liveGroup;
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a swimlane divider element.
|
2026-05-28 12:07:56 +02:00
|
|
|
*
|
|
|
|
|
* When `headerNodeFn` is supplied the header renders a rich DOM node
|
|
|
|
|
* (e.g. a user vignette) instead of plain text; the `--node` CSS modifier
|
|
|
|
|
* is added to suppress the small-caps / uppercase text styles.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} key - Raw grouping key (e.g. UUID or bucket name).
|
|
|
|
|
* @param {((key: string) => string)=} labelFn - Optional plain-text resolver.
|
|
|
|
|
* @param {((key: string) => HTMLElement)=} headerNodeFn - Optional rich-node builder.
|
2026-05-26 19:51:40 +02:00
|
|
|
*/
|
2026-05-28 12:07:56 +02:00
|
|
|
_createGroupHeader(key, labelFn, headerNodeFn) {
|
2026-05-26 19:51:40 +02:00
|
|
|
const el = document.createElement('div');
|
|
|
|
|
el.className = 'resource-list__swimlane-header';
|
|
|
|
|
el.dataset.swimlaneHeader = 'true';
|
2026-05-28 12:07:56 +02:00
|
|
|
if (headerNodeFn) {
|
|
|
|
|
el.classList.add('resource-list__swimlane-header--node');
|
|
|
|
|
el.appendChild(headerNodeFn(key));
|
|
|
|
|
} else {
|
|
|
|
|
el.textContent = labelFn ? labelFn(key) : key;
|
|
|
|
|
}
|
2026-05-26 19:51:40 +02:00
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build a .file-item DOM element for a folder.
|
|
|
|
|
* @param {FolderItem} folder
|
|
|
|
|
* @returns {HTMLElement}
|
|
|
|
|
*/
|
|
|
|
|
_createFolderItem(folder) {
|
|
|
|
|
const cfg = this._cfg;
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
|
|
|
|
|
el.className = `file-item${modClass}`;
|
|
|
|
|
el.dataset.folderId = folder.id;
|
|
|
|
|
el.dataset.folderName = folder.name;
|
|
|
|
|
el.dataset.parentId = folder.parent_id || '';
|
|
|
|
|
if (folder.path) el.dataset.path = folder.path;
|
2026-05-28 10:51:50 +02:00
|
|
|
if (folder.owner_id) el.dataset.ownerId = folder.owner_id;
|
2026-05-26 19:51:40 +02:00
|
|
|
if (cfg.draggable) el.setAttribute('draggable', 'true');
|
|
|
|
|
|
|
|
|
|
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
|
|
|
|
|
const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false;
|
|
|
|
|
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at;
|
|
|
|
|
const formattedDate = formatDateTime(new Date(dateVal));
|
|
|
|
|
|
|
|
|
|
el.innerHTML = `
|
|
|
|
|
${cfg.selectable ? '<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>
|
|
|
|
|
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
|
|
|
|
${cfg.showShareBadge ? `<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">
|
|
|
|
|
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
|
|
|
|
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
this._bindItemEvents(el, folder);
|
|
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build a .file-item DOM element for a file.
|
|
|
|
|
* @param {FileItem} file
|
|
|
|
|
* @returns {HTMLElement}
|
|
|
|
|
*/
|
|
|
|
|
_createFileItem(file) {
|
|
|
|
|
const cfg = this._cfg;
|
|
|
|
|
const iconClass = file.icon_class || 'fas fa-file';
|
|
|
|
|
const iconSpecialClass = file.icon_special_class || '';
|
|
|
|
|
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 dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (file))[cfg.dateField] ?? file.modified_at;
|
|
|
|
|
const formattedDate = formatDateTime(new Date(dateVal));
|
|
|
|
|
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
|
|
|
|
|
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
|
|
|
|
|
const canThumbnail = thumbnail?.canHandle(file) ?? false;
|
|
|
|
|
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
const modClass = cfg.itemModifierClass ? ` ${cfg.itemModifierClass}` : '';
|
|
|
|
|
el.className = `file-item${modClass}`;
|
|
|
|
|
el.dataset.fileId = file.id;
|
|
|
|
|
el.dataset.fileName = file.name;
|
|
|
|
|
el.dataset.folderId = file.folder_id || '';
|
|
|
|
|
if (file.path) el.dataset.path = file.path;
|
2026-05-28 10:51:50 +02:00
|
|
|
if (file.owner_id) el.dataset.ownerId = file.owner_id;
|
2026-05-26 19:51:40 +02:00
|
|
|
if (cfg.draggable) el.setAttribute('draggable', 'true');
|
|
|
|
|
|
|
|
|
|
el.innerHTML = `
|
|
|
|
|
${cfg.selectable ? '<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>
|
|
|
|
|
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
|
|
|
|
${cfg.showShareBadge ? `<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">
|
|
|
|
|
${cfg.showFavorite ? `<button class="favorite-star${isFav ? ' active' : ''}"><i class="${isFav ? 'fas' : 'far'} fa-star"></i></button>` : ''}
|
|
|
|
|
${cfg.showContextMenu ? '<button class="file-actions"><i class="fas fa-ellipsis-v"></i></button>' : ''}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const thumb = /** @type {HTMLImageElement | null} */ (el.querySelector('.file-thumb'));
|
|
|
|
|
if (thumb) {
|
|
|
|
|
thumb.addEventListener('error', () => {
|
2026-05-28 14:17:20 +02:00
|
|
|
console.log(`no thumbnail for ${file.id} (${file.name}), request thumbnail generation from client side`);
|
2026-05-26 19:51:40 +02:00
|
|
|
thumb.classList.add('hidden');
|
|
|
|
|
thumbnail?.queueGenerate(file, (dataUrl) => {
|
|
|
|
|
thumb.src = dataUrl;
|
|
|
|
|
thumb.classList.remove('hidden');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._bindItemEvents(el, file);
|
|
|
|
|
return el;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Attach direct event listeners to interactive elements inside a .file-item.
|
|
|
|
|
* This covers buttons that must stop propagation before the delegated listener runs.
|
|
|
|
|
* @param {HTMLElement} el
|
|
|
|
|
* @param {FileItem|FolderItem} item
|
|
|
|
|
*/
|
|
|
|
|
_bindItemEvents(el, item) {
|
|
|
|
|
const cfg = this._cfg;
|
|
|
|
|
|
|
|
|
|
// Favorite-star — direct click, stopPropagation so the card open doesn't fire
|
|
|
|
|
if (cfg.showFavorite && cfg.onFavoriteToggle) {
|
|
|
|
|
const star = el.querySelector('.favorite-star');
|
|
|
|
|
star?.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.stopImmediatePropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
cfg.onFavoriteToggle?.(item);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Shared-badge click → treat as context-menu trigger (e.g. open share modal)
|
|
|
|
|
if (cfg.showShareBadge && cfg.onContextMenu) {
|
|
|
|
|
const badge = el.querySelector('.file-badge-shared');
|
|
|
|
|
badge?.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.stopImmediatePropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
cfg.onContextMenu?.(item, /** @type {MouseEvent} */ (e));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Wire one delegated listener for all pointer events in this container. */
|
|
|
|
|
_initDelegation() {
|
|
|
|
|
const container = this._container;
|
|
|
|
|
const cfg = this._cfg;
|
|
|
|
|
|
|
|
|
|
// ── click ──────────────────────────────────────────────────────────
|
|
|
|
|
container.addEventListener('click', (e) => {
|
|
|
|
|
const target = /** @type {HTMLElement} */ (e.target);
|
|
|
|
|
|
|
|
|
|
// Swimlane dividers are not interactive
|
|
|
|
|
if (target.dataset.swimlaneHeader) return;
|
|
|
|
|
|
|
|
|
|
const card = /** @type {HTMLElement | null} */ (target.closest('.file-item'));
|
|
|
|
|
if (!card) return;
|
|
|
|
|
|
|
|
|
|
// Three-dots button → context menu
|
|
|
|
|
if (target.closest('.file-actions')) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const item = this._itemFromCard(card);
|
|
|
|
|
if (item && cfg.onContextMenu) cfg.onContextMenu(item, /** @type {MouseEvent} */ (e));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:59:43 +02:00
|
|
|
// Checkbox cell → selection (shift extends range)
|
2026-05-26 19:51:40 +02:00
|
|
|
if (cfg.selectable && target.closest('.checkbox-cell')) {
|
2026-05-26 22:59:43 +02:00
|
|
|
if (e.shiftKey) {
|
|
|
|
|
this._handleShiftSelect(card);
|
|
|
|
|
} else {
|
|
|
|
|
this._toggleSelection(card);
|
|
|
|
|
}
|
2026-05-26 19:51:40 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Favorite star is handled by the direct listener in _bindItemEvents
|
|
|
|
|
if (target.closest('.favorite-star')) return;
|
|
|
|
|
|
|
|
|
|
// Modifier-key click → selection toggle
|
|
|
|
|
if (e.metaKey || e.altKey || e.ctrlKey) {
|
2026-05-26 22:59:43 +02:00
|
|
|
if (cfg.selectable) this._toggleSelection(card);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Shift-click anywhere on the card → extend selection range
|
|
|
|
|
if (e.shiftKey && cfg.selectable) {
|
|
|
|
|
this._handleShiftSelect(card);
|
2026-05-26 19:51:40 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Plain click → open or navigate
|
|
|
|
|
const item = this._itemFromCard(card);
|
|
|
|
|
if (item && cfg.onOpen) cfg.onOpen(item, /** @type {MouseEvent} */ (e));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── contextmenu ────────────────────────────────────────────────────
|
|
|
|
|
if (cfg.showContextMenu) {
|
|
|
|
|
container.addEventListener('contextmenu', (e) => {
|
|
|
|
|
const target = /** @type {HTMLElement} */ (e.target);
|
|
|
|
|
if (target.dataset.swimlaneHeader) return;
|
|
|
|
|
const card = /** @type {HTMLElement | null} */ (target.closest('.file-item'));
|
|
|
|
|
if (!card) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const item = this._itemFromCard(card);
|
|
|
|
|
if (item && cfg.onContextMenu) cfg.onContextMenu(item, /** @type {MouseEvent} */ (e));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── dblclick — prevent double-fire of open on rapid clicks ─────────
|
|
|
|
|
container.addEventListener('dblclick', (e) => e.preventDefault());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Return the registered item object for a given card element.
|
|
|
|
|
* @param {HTMLElement} card
|
|
|
|
|
* @returns {FileItem|FolderItem|undefined}
|
|
|
|
|
*/
|
|
|
|
|
_itemFromCard(card) {
|
|
|
|
|
const id = card.dataset.fileId || card.dataset.folderId || '';
|
|
|
|
|
return this._items.get(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-26 22:59:43 +02:00
|
|
|
* Toggle selection on a single card and notify.
|
|
|
|
|
* Tracks `_lastClickedIndex` for subsequent shift-clicks.
|
2026-05-26 19:51:40 +02:00
|
|
|
* @param {HTMLElement} card
|
|
|
|
|
*/
|
2026-05-26 22:59:43 +02:00
|
|
|
_toggleSelection(card) {
|
2026-05-26 19:51:40 +02:00
|
|
|
const id = card.dataset.fileId || card.dataset.folderId || '';
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
|
|
|
|
const nowSelected = !card.classList.contains('selected');
|
|
|
|
|
card.classList.toggle('selected', nowSelected);
|
|
|
|
|
|
|
|
|
|
const checkbox = /** @type {HTMLInputElement | null} */ (card.querySelector('.item-checkbox'));
|
|
|
|
|
if (checkbox) checkbox.checked = nowSelected;
|
|
|
|
|
|
|
|
|
|
if (nowSelected) {
|
|
|
|
|
this._selected.add(id);
|
|
|
|
|
} else {
|
|
|
|
|
this._selected.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:59:43 +02:00
|
|
|
// Record position for the next shift-click
|
|
|
|
|
const items = [...this._container.querySelectorAll('.file-item')];
|
|
|
|
|
this._lastClickedIndex = items.indexOf(card);
|
|
|
|
|
|
|
|
|
|
this._syncSelectAllCheckbox();
|
|
|
|
|
this._notifySelectionChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extend the selection from `_lastClickedIndex` to `card` (inclusive).
|
|
|
|
|
* If no previous click exists, falls back to a plain toggle.
|
|
|
|
|
* @param {HTMLElement} card
|
|
|
|
|
*/
|
|
|
|
|
_handleShiftSelect(card) {
|
|
|
|
|
const items = /** @type {HTMLElement[]} */ ([...this._container.querySelectorAll('.file-item')]);
|
|
|
|
|
const index = items.indexOf(card);
|
|
|
|
|
|
|
|
|
|
if (this._lastClickedIndex >= 0 && index >= 0) {
|
|
|
|
|
const start = Math.min(this._lastClickedIndex, index);
|
|
|
|
|
const end = Math.max(this._lastClickedIndex, index);
|
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
|
|
|
const el = items[i];
|
|
|
|
|
const id = el.dataset.fileId || el.dataset.folderId || '';
|
|
|
|
|
if (!id) continue;
|
|
|
|
|
el.classList.add('selected');
|
|
|
|
|
const cb = /** @type {HTMLInputElement | null} */ (el.querySelector('.item-checkbox'));
|
|
|
|
|
if (cb) cb.checked = true;
|
|
|
|
|
this._selected.add(id);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this._toggleSelection(card);
|
|
|
|
|
return;
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
2026-05-26 22:59:43 +02:00
|
|
|
|
|
|
|
|
this._lastClickedIndex = index;
|
|
|
|
|
this._syncSelectAllCheckbox();
|
|
|
|
|
this._notifySelectionChange();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find the select-all checkbox in the container header and wire its
|
|
|
|
|
* `change` event. Called after every `render()`.
|
|
|
|
|
*/
|
|
|
|
|
_wireSelectAll() {
|
|
|
|
|
if (!this._cfg.selectable) return;
|
|
|
|
|
const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox'));
|
|
|
|
|
if (!cb) return;
|
|
|
|
|
// Replace with a fresh listener to avoid duplicates across re-renders
|
|
|
|
|
const fresh = /** @type {HTMLInputElement} */ (cb.cloneNode(true));
|
|
|
|
|
cb.parentNode?.replaceChild(fresh, cb);
|
|
|
|
|
fresh.addEventListener('change', () => {
|
|
|
|
|
if (fresh.checked) {
|
|
|
|
|
this.selectAll();
|
|
|
|
|
} else {
|
|
|
|
|
this.clearSelection();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sync the three-state select-all checkbox in the list header.
|
|
|
|
|
* Checked = all selected, indeterminate = some selected, unchecked = none.
|
|
|
|
|
*/
|
|
|
|
|
_syncSelectAllCheckbox() {
|
|
|
|
|
const cb = /** @type {HTMLInputElement | null} */ (this._container.querySelector('#select-all-checkbox'));
|
|
|
|
|
if (!cb) return;
|
|
|
|
|
const total = this._container.querySelectorAll('.file-item').length;
|
|
|
|
|
if (total === 0) {
|
|
|
|
|
cb.checked = false;
|
|
|
|
|
cb.indeterminate = false;
|
|
|
|
|
} else if (this._selected.size >= total) {
|
|
|
|
|
cb.checked = true;
|
|
|
|
|
cb.indeterminate = false;
|
|
|
|
|
} else if (this._selected.size > 0) {
|
|
|
|
|
cb.checked = false;
|
|
|
|
|
cb.indeterminate = true;
|
|
|
|
|
} else {
|
|
|
|
|
cb.checked = false;
|
|
|
|
|
cb.indeterminate = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Build the selected-items array and fire `onSelectionChange`. */
|
|
|
|
|
_notifySelectionChange() {
|
|
|
|
|
if (!this._cfg.onSelectionChange) return;
|
|
|
|
|
/** @type {Array<FileItem|FolderItem>} */
|
|
|
|
|
const selectedItems = [...this._selected].flatMap((id) => {
|
|
|
|
|
const item = this._items.get(id);
|
|
|
|
|
return item ? [item] : [];
|
|
|
|
|
});
|
|
|
|
|
this._cfg.onSelectionChange(selectedItems);
|
2026-05-26 19:51:40 +02:00
|
|
|
}
|
|
|
|
|
}
|