diff --git a/static/css/components/fileManager.css b/static/css/components/fileManager.css new file mode 100644 index 00000000..dec439b4 --- /dev/null +++ b/static/css/components/fileManager.css @@ -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; +} diff --git a/static/css/components/multiSelect.css b/static/css/components/multiSelect.css index ed54c193..ee782373 100644 --- a/static/css/components/multiSelect.css +++ b/static/css/components/multiSelect.css @@ -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; diff --git a/static/css/components/filesView.css b/static/css/components/resourceList.css similarity index 76% rename from static/css/components/filesView.css rename to static/css/components/resourceList.css index 3a129f6d..ad57be34 100644 --- a/static/css/components/filesView.css +++ b/static/css/components/resourceList.css @@ -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; +} diff --git a/static/css/main.css b/static/css/main.css index de5d36bc..380b2f4b 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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"); diff --git a/static/css/views/favorites.css b/static/css/views/favorites.css index 8b09a670..e2ee4917 100644 --- a/static/css/views/favorites.css +++ b/static/css/views/favorites.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; diff --git a/static/css/views/recent.css b/static/css/views/recent.css index a1d2b557..79ecea9c 100644 --- a/static/css/views/recent.css +++ b/static/css/views/recent.css @@ -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; diff --git a/static/css/views/trash.css b/static/css/views/trash.css index bfed0a15..53899cfb 100644 --- a/static/css/views/trash.css +++ b/static/css/views/trash.css @@ -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; diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js new file mode 100644 index 00000000..7d4af5b0 --- /dev/null +++ b/static/js/components/resourceList.js @@ -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} [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) => 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> & 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} */ + this._items = new Map(); + + /** IDs of currently selected items. */ + /** @type {Set} */ + 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} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at; + const formattedDate = formatDateTime(new Date(dateVal)); + + el.innerHTML = ` + ${cfg.selectable ? '
' : ''} +
+
+ +
+ ${escapeHtml(folder.name)} + ${cfg.showFavorite ? `
` : ''} + ${cfg.showShareBadge ? `
` : ''} +
+
+
${i18n.t('files.file_types.folder')}
+
--
+
${formattedDate}
+
+ ${cfg.showFavorite ? `` : ''} + ${cfg.showContextMenu ? '' : ''} +
+ `; + + 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} */ (/** @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 ? '
' : ''} +
+
+ ${canThumbnail ? `` : ''} + +
+ ${escapeHtml(file.name)} + ${cfg.showFavorite ? `
` : ''} + ${cfg.showShareBadge ? `
` : ''} +
+
+
${typeLabel}
+
${fileSize}
+
${formattedDate}
+
+ ${cfg.showFavorite ? `` : ''} + ${cfg.showContextMenu ? '' : ''} +
+ `; + + 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} */ + const selectedItems = [...this._selected].flatMap((sid) => { + const item = this._items.get(sid); + return item ? [item] : []; + }); + this._cfg.onSelectionChange(selectedItems); + } + } +} diff --git a/static/js/core/types.js b/static/js/core/types.js index 51a561b8..343e8091 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -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} [onFavoriteToggle] - Favorite-star click callback. + * @property {(item: FileItem|FolderItem, event: MouseEvent) => void} [onContextMenu] - Context menu callback. + * @property {(selected: Array) => void} [onSelectionChange] - Selection change callback. + */ + /** * One item returned by `GET /api/grants/incoming/resources`. * `resource_type` discriminates the shape of `resource`.