refactor(resourceList): move grid/list view into a resourceList component, purpose normalize on all sections views
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/* File-manager page shell — layout concerns specific to the file-manager section.
|
||||
* Item rendering (grid cards, list rows, drag ghost) lives in resourceList.css. */
|
||||
|
||||
.files-container {
|
||||
padding-top: 3px; /* cards animate on hover; sticky header has positive z-index */
|
||||
}
|
||||
|
||||
/* Rubber band / lasso selection rectangle */
|
||||
.selection-rect {
|
||||
position: fixed;
|
||||
border: 1.5px solid var(--primary-color, var(--color-card-drop-border));
|
||||
background-color: var(--color-card-drop-tint);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
border-radius: 3px;
|
||||
display: none;
|
||||
}
|
||||
@@ -1,30 +1,6 @@
|
||||
/* Multi-Select – checkboxes & batch action bar */
|
||||
.list-header-checkbox,
|
||||
.file-item .checkbox-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-header-checkbox input[type="checkbox"],
|
||||
.file-item .checkbox-cell input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list-header.selection-mode {
|
||||
grid-template-columns: 36px 1fr;
|
||||
background-color: var(--color-multiselect-bg);
|
||||
color: var(--color-multiselect-text);
|
||||
border-bottom-color: var(--color-multiselect-border);
|
||||
}
|
||||
|
||||
.list-header.selection-mode .list-header-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
/* Multi-Select – batch action toolbar
|
||||
* Per-item checkbox styles (.file-item .checkbox-cell, .list-header.selection-mode)
|
||||
* live in resourceList.css alongside the item renderer. */
|
||||
|
||||
.batch-selection-info {
|
||||
display: flex;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
.files-container {
|
||||
padding-top: 3px; /* due to cards animation on mouse hover and page-sticky-header with a positive z-index */
|
||||
}
|
||||
/* ============================================================
|
||||
* ResourceList component styles
|
||||
*
|
||||
* All styles for .file-item (both grid cards and list rows),
|
||||
* the list header, drag ghost, per-item checkboxes, and
|
||||
* section-specific item modifiers (.favorite-item, .recent-item,
|
||||
* .trash-item).
|
||||
*
|
||||
* Page-level shell (.files-container, .selection-rect) → fileManager.css
|
||||
* Batch-action toolbar (.batch-selection-bar, .batch-btn …) → multiSelect.css
|
||||
* Section page headers (.list-header.favorites-header …) → views/*.css
|
||||
* ============================================================ */
|
||||
|
||||
/* Rubber band / lasso selection rectangle */
|
||||
.selection-rect {
|
||||
position: fixed;
|
||||
border: 1.5px solid var(--primary-color, var(--color-card-drop-border));
|
||||
background-color: var(--color-card-drop-tint);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
border-radius: 3px;
|
||||
display: none;
|
||||
}
|
||||
/* ── Base icon container ─────────────────────────────────── */
|
||||
|
||||
.file-icon {
|
||||
width: 100px;
|
||||
@@ -35,6 +35,8 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Item states ─────────────────────────────────────────── */
|
||||
|
||||
.file-item {
|
||||
background-color: var(--color-item);
|
||||
}
|
||||
@@ -73,7 +75,36 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ------------------File list View --------------------- */
|
||||
/* ── Per-item checkboxes (list + grid) ───────────────────── */
|
||||
|
||||
.list-header-checkbox,
|
||||
.file-item .checkbox-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-header-checkbox input[type="checkbox"],
|
||||
.file-item .checkbox-cell input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list-header.selection-mode {
|
||||
grid-template-columns: 36px 1fr;
|
||||
background-color: var(--color-multiselect-bg);
|
||||
color: var(--color-multiselect-text);
|
||||
border-bottom-color: var(--color-multiselect-border);
|
||||
}
|
||||
|
||||
.list-header.selection-mode .list-header-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── List view ───────────────────────────────────────────── */
|
||||
|
||||
.list-header {
|
||||
display: grid;
|
||||
@@ -108,7 +139,7 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Owner column ─────────────────────────────────────────── */
|
||||
/* ── Owner column ────────────────────────────────────────── */
|
||||
|
||||
/* Styles applied whenever the cell is visible (hidden class absent).
|
||||
The .hidden utility class (display:none !important) keeps it invisible
|
||||
@@ -183,6 +214,7 @@
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.files-list-view .file-item .date-cell {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
@@ -236,7 +268,7 @@
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* --------------------- Files grid view --------------------------- */
|
||||
/* ── Grid view ───────────────────────────────────────────── */
|
||||
|
||||
.files-grid-view {
|
||||
display: grid;
|
||||
@@ -289,12 +321,13 @@
|
||||
0 6px 18px var(--color-accent-ring-dark);
|
||||
}
|
||||
|
||||
/* element hidden on grid view */
|
||||
/* elements hidden in grid view */
|
||||
.files-grid-view .file-item .date-cell,
|
||||
.files-grid-view .file-item .size-cell,
|
||||
.files-grid-view .file-item .owner-cell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Selection checkbox */
|
||||
.files-grid-view .file-item .checkbox-cell {
|
||||
position: absolute;
|
||||
@@ -373,7 +406,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
z-index: 12;
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
@@ -461,7 +493,7 @@
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
/* ----------------------- dragged items -----------*/
|
||||
/* ── Drag ghost ──────────────────────────────────────────── */
|
||||
|
||||
.dragged-items {
|
||||
--dragged-files-list-columns: 36px minmax(200px, 1fr);
|
||||
@@ -484,7 +516,7 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* file name, text is wrapped */
|
||||
/* file name text is truncated */
|
||||
.dragged-items .file-item div:nth-child(2) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -505,22 +537,75 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
transform: translate(50%, -50%);
|
||||
|
||||
background: var(--color-content-debug-bg);
|
||||
color: var(--color-content-debug-text);
|
||||
|
||||
border-radius: 50%;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ── Section item modifiers ──────────────────────────────── */
|
||||
|
||||
/* — Favorites — */
|
||||
|
||||
.file-item.favorite-item {
|
||||
border-left: 3px solid var(--color-warning-border);
|
||||
|
||||
[dir="rtl"] & {
|
||||
border-right: 3px solid var(--color-warning-border);
|
||||
border-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* list-view column override */
|
||||
.file-item.favorite-item {
|
||||
position: relative;
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.favorite-item .favorite-indicator {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* — Recent — */
|
||||
|
||||
.files-grid-view .file-item.recent-item {
|
||||
border-left: 3px solid var(--color-recent-border);
|
||||
|
||||
[dir="rtl"] & {
|
||||
border-right: 3px solid var(--color-recent-border);
|
||||
border-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* list-view column override */
|
||||
.file-item.recent-item {
|
||||
position: relative;
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.recent-item .recent-indicator {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* — Trash — */
|
||||
|
||||
/* path-cell is shown in trashView; hide it in non-trash contexts */
|
||||
.file-item.trash-item > .path-cell {
|
||||
display: none;
|
||||
}
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
@import url("./components/breadcrumb.css");
|
||||
@import url("./components/buttons.css");
|
||||
@import url("./components/fileType.css");
|
||||
@import url("./components/filesView.css");
|
||||
@import url("./components/fileManager.css");
|
||||
@import url("./components/resourceList.css");
|
||||
@import url("./components/contextMenu.css");
|
||||
@import url("./components/dialogs.css");
|
||||
@import url("./components/modals.css");
|
||||
|
||||
@@ -31,39 +31,17 @@
|
||||
text-shadow: 0 0 5px var(--color-warning-shadow);
|
||||
}
|
||||
|
||||
/* Styles for the favorites view items */
|
||||
/* Item modifier rules (.file-item.favorite-item) → resourceList.css */
|
||||
|
||||
.favorite-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Adjustments for grid view */
|
||||
.file-item.favorite-item {
|
||||
border-left: 3px solid var(--color-warning-border);
|
||||
|
||||
[dir="rtl"] & {
|
||||
border-right: 3px solid var(--color-warning-border);
|
||||
border-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjustments for list view */
|
||||
/* Adjustments for list view header (page-level, not item-level) */
|
||||
.list-header.favorites-header {
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.favorite-item {
|
||||
position: relative;
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.favorite-item .favorite-indicator {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* Styles for the favorites-specific empty state */
|
||||
.favorites-empty-state {
|
||||
display: flex;
|
||||
|
||||
@@ -20,39 +20,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles for the recent view items */
|
||||
/* Item modifier rules (.file-item.recent-item) → resourceList.css */
|
||||
|
||||
.recent-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Adjustments for grid view */
|
||||
.files-grid-view .file-item.recent-item {
|
||||
border-left: 3px solid var(--color-recent-border);
|
||||
|
||||
[dir="rtl"] & {
|
||||
border-right: 3px solid var(--color-recent-border);
|
||||
border-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjustments for list view */
|
||||
/* Adjustments for list view header (page-level, not item-level) */
|
||||
.list-header.recent-header {
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.recent-item {
|
||||
position: relative;
|
||||
grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px;
|
||||
}
|
||||
|
||||
.file-item.recent-item .recent-indicator {
|
||||
position: relative;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
/* Styles for the recent-specific empty state */
|
||||
.recents-empty-state {
|
||||
display: flex;
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
color: var(--color-trash-delete);
|
||||
}
|
||||
|
||||
.file-item.trash-item > .path-cell {
|
||||
display: none;
|
||||
}
|
||||
/* .file-item.trash-item > .path-cell → resourceList.css */
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* @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();
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {FolderItem[]} folders
|
||||
* @param {FileItem[]} files
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
* When provided, a swimlane divider is injected whenever the returned
|
||||
* label changes. Return `null` to suppress the divider for that item.
|
||||
*/
|
||||
render(folders, files, groupFn) {
|
||||
const header = this._container.querySelector('.list-header');
|
||||
this._container.innerHTML = '';
|
||||
if (header) this._container.appendChild(header);
|
||||
|
||||
this._selected.clear();
|
||||
this._items.clear();
|
||||
|
||||
this._appendItems(folders, files, groupFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append additional items without clearing the existing ones (load-more).
|
||||
*
|
||||
* @param {FolderItem[]} folders
|
||||
* @param {FileItem[]} files
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
*/
|
||||
append(folders, files, groupFn) {
|
||||
this._appendItems(folders, files, groupFn);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {FolderItem[]} folders
|
||||
* @param {FileItem[]} files
|
||||
* @param {((item: FileItem|FolderItem) => string|null)=} groupFn
|
||||
*/
|
||||
_appendItems(folders, files, groupFn) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastGroupKey = /** @type {string|null|undefined} */ (undefined);
|
||||
|
||||
for (const folder of folders) {
|
||||
this._items.set(folder.id, folder);
|
||||
if (groupFn) {
|
||||
const key = groupFn(folder);
|
||||
if (key !== lastGroupKey) {
|
||||
lastGroupKey = key;
|
||||
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
|
||||
}
|
||||
}
|
||||
fragment.appendChild(this._createFolderItem(folder));
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
this._items.set(file.id, file);
|
||||
if (groupFn) {
|
||||
const key = groupFn(file);
|
||||
if (key !== lastGroupKey) {
|
||||
lastGroupKey = key;
|
||||
if (key !== null) fragment.appendChild(this._createGroupHeader(key));
|
||||
}
|
||||
}
|
||||
fragment.appendChild(this._createFileItem(file));
|
||||
}
|
||||
|
||||
this._container.appendChild(fragment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a swimlane divider element.
|
||||
* @param {string} label
|
||||
*/
|
||||
_createGroupHeader(label) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'resource-list__swimlane-header';
|
||||
el.dataset.swimlaneHeader = 'true';
|
||||
el.textContent = label;
|
||||
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;
|
||||
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;
|
||||
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', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
// Checkbox cell → selection
|
||||
if (cfg.selectable && target.closest('.checkbox-cell')) {
|
||||
this._toggleSelection(card, /** @type {MouseEvent} */ (e));
|
||||
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) {
|
||||
if (cfg.selectable) this._toggleSelection(card, /** @type {MouseEvent} */ (e));
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle selection state on a card and notify via `onSelectionChange`.
|
||||
* @param {HTMLElement} card
|
||||
* @param {MouseEvent} _e - Reserved for future shift-click range selection.
|
||||
*/
|
||||
_toggleSelection(card, _e) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (this._cfg.onSelectionChange) {
|
||||
/** @type {Array<FileItem|FolderItem>} */
|
||||
const selectedItems = [...this._selected].flatMap((sid) => {
|
||||
const item = this._items.get(sid);
|
||||
return item ? [item] : [];
|
||||
});
|
||||
this._cfg.onSelectionChange(selectedItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +295,26 @@
|
||||
* Roles: `viewer`, `commenter`, `editor`, `manager`, `admin`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for `ResourceListComponent`.
|
||||
* @typedef {Object} ResourceListConfig
|
||||
* @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.
|
||||
* @property {boolean} [showContextMenu=true] - Enable the three-dots button and right-click menu.
|
||||
* @property {string} [itemModifierClass] - Extra CSS class on every .file-item (e.g. 'favorite-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.
|
||||
* @property {(id: string, type: 'file'|'folder') => boolean} [isFavorite] - State provider for favorite badge.
|
||||
* @property {(id: string, type: 'file'|'folder') => boolean} [isShared] - State provider for share badge.
|
||||
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onOpen] - Item open/navigate callback.
|
||||
* @property {(item: FileItem|FolderItem) => Promise<void>} [onFavoriteToggle] - Favorite-star click callback.
|
||||
* @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu] - Context menu callback.
|
||||
* @property {(selected: Array<FileItem|FolderItem>) => void} [onSelectionChange] - Selection change callback.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One item returned by `GET /api/grants/incoming/resources`.
|
||||
* `resource_type` discriminates the shape of `resource`.
|
||||
|
||||
Reference in New Issue
Block a user