style(front/js): apply types on all objects

- reduce amount of warnings in IDE
    - maximize API type mapping with static/js/core/types.js
This commit is contained in:
Edouard Vanbelle
2026-05-07 23:40:02 +02:00
parent a38475bd2c
commit fac184ccfe
43 changed files with 1614 additions and 574 deletions
+49 -15
View File
@@ -20,10 +20,19 @@ import { inlineViewer } from './inlineViewer.js';
import { multiSelect } from './multiSelect.js';
import { wopiEditor } from './wopiEditor.js';
/**
* @import {FolderItem, FileItem, ItemTypeEnum, Playlist} from '../../core/types.js'
*/
/** @type {EventListener | null} */
let _moveDialogEscapeHandler = null;
// Context Menus Module
const contextMenus = {
/**
* @param {string} optionId
* @param {boolean} isFavorite
*/
_setFavoriteOptionLabel(optionId, isFavorite) {
const option = document.getElementById(optionId);
if (!option) return;
@@ -308,11 +317,13 @@ const contextMenus = {
// Note: We don't use stopPropagation because all Escape handlers are on document level
// Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses
if (!_moveDialogEscapeHandler) {
_moveDialogEscapeHandler = (e) => {
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
this.closeMoveDialog();
_moveDialogEscapeHandler = /** @type {EventListener} */ (
(/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
this.closeMoveDialog();
}
}
};
);
document.addEventListener('keydown', _moveDialogEscapeHandler);
}
@@ -385,8 +396,8 @@ const contextMenus = {
/**
* Show move dialog for a file or folder
* @param {Object} item - File or folder object
* @param {string} mode - 'file' or 'folder'
* @param {FolderItem | FileItem} item - File or folder object
* @param {ItemTypeEnum} mode
*/
async showMoveDialog(item, mode) {
// Set mode
@@ -405,15 +416,15 @@ const contextMenus = {
// Start at the parent of the item being moved (so user sees siblings and can navigate)
let startFolderId = null;
let startFolderName = null;
if (mode === 'file' && item.folder_id) {
startFolderId = item.folder_id;
if (mode === 'file' && /** @type {FileItem} */ (item).folder_id) {
startFolderId = /** @type {FileItem} */ (item).folder_id;
// We need the folder name for breadcrumb - try to get it from current view
const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`);
if (folderEl) {
startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null;
}
} else if (mode === 'folder' && item.parent_id) {
startFolderId = item.parent_id;
} else if (mode === 'folder' && /** @type {FolderItem} */ (item).parent_id) {
startFolderId = /** @type {FolderItem} */ (item).parent_id;
} else {
// If item is at root level, start at user's home folder
startFolderId = app.userHomeFolderId || null;
@@ -492,6 +503,7 @@ const contextMenus = {
// The contents endpoint returns an array of child folders
// The fallback /api/folders returns root folders (home folder itself)
/** @type {FolderItem[]} */
const folders = Array.isArray(data) ? data : data.folders || [];
console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
@@ -623,6 +635,9 @@ const contextMenus = {
/**
* Render breadcrumb navigation for move dialog
* @param {HTMLElement | null} container
* @param {Array<{id: string, name: string}>} breadcrumb
* @param {string | null} _currentFolderId
*/
_renderMoveDialogBreadcrumb(container, breadcrumb, _currentFolderId) {
if (!container) return;
@@ -666,7 +681,7 @@ const contextMenus = {
}
// Breadcrumb path
breadcrumb.forEach((segment, index) => {
breadcrumb.forEach((/** @type {{id: string, name: string}} */ segment, /** @type {number} */ index) => {
const separator = document.createElement('span');
separator.className = 'move-breadcrumb-separator';
separator.textContent = '>';
@@ -709,8 +724,8 @@ const contextMenus = {
/**
* Show share dialog for files or folders
* @param {Object} item - File or folder object
* @param {string} itemType - 'file' or 'folder'
* @param {FileItem | FolderItem} item - File or folder object
* @param {ItemTypeEnum} itemType
*/
async showShareDialog(item, itemType) {
try {
@@ -835,7 +850,7 @@ const contextMenus = {
btn.closest('.existing-share-item').remove();
if (existingSharesContainer.children.length === 0) {
document.getElementById('existing-shares-section').classList.add('hidden');
ui.setSharedVisualState(item.id, item.type, false);
ui.setSharedVisualState(item.id, itemType, false);
}
}
});
@@ -920,7 +935,7 @@ const contextMenus = {
}
// Update Item's shared badge
ui.setSharedVisualState(item.id, item.type, true);
ui.setSharedVisualState(item.id, itemType, true);
// Show success message
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
@@ -994,8 +1009,14 @@ const contextMenus = {
app.notificationShareUrl = null;
},
/** @type {String | null} */
_selectedPlaylistId: null,
/**
*
* @param {FileItem} file
* @returns
*/
async showPlaylistDialog(file) {
const dialog = document.getElementById('playlist-dialog');
const container = document.getElementById('playlist-select-container');
@@ -1031,6 +1052,7 @@ const contextMenus = {
const resp = await fetch('/api/playlists', { credentials: 'include' });
if (!resp.ok) throw new Error('Failed to load playlists');
/** @type {Playlist[]} */
const playlists = await resp.json();
this._renderPlaylistSelect(container, playlists);
} catch (err) {
@@ -1039,6 +1061,12 @@ const contextMenus = {
}
},
/**
*
* @param {HTMLElement} container
* @param {Playlist[]} playlists
* @returns
*/
_renderPlaylistSelect(container, playlists) {
container.innerHTML = '';
@@ -1124,6 +1152,12 @@ const contextMenus = {
this._selectedPlaylistId = null;
},
/**
*
* @param {string} str
* @returns
*/
//FIXME: move to common library
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+74 -31
View File
@@ -11,10 +11,18 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { notifications } from '../../core/notifications.js';
/** @import {TrashItem} from '../../core/types.js' */
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/**
* Get authorization headers for API requests.
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
* @returns {Object} Headers object
* @returns {Record<String, String>} Headers object
*/
function getAuthHeaders() {
return { ...getCsrfHeaders() };
@@ -25,15 +33,26 @@ const fileOps = {
// ========================================================================
// Upload progress — notification bell integration
// ========================================================================
/** @type {string | null} */
_currentBatchId: null,
/** @type {boolean} */
_isUploading: false, // Guard against concurrent upload calls
/** Start a new upload batch in the notification bell */
/**
* Start a new upload batch in the notification bell
* @param {number} totalFiles
* @param {string} [folderName]
*/
_initUploadToast(totalFiles, folderName) {
this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName);
},
/** Finalise the batch in the notification bell */
/**
* Finalise the batch in the notification bell
* @param {number} successCount
* @param {number} totalFiles
* */
_finishUploadToast(successCount, totalFiles) {
if (this._currentBatchId) {
notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
@@ -44,6 +63,8 @@ const fileOps = {
* Some drag-and-drop sources can inject directory placeholders into
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
* when trying to send them as normal files.
* @param {File} file
* @returns {Promise<boolean>}
*/
_canReadFileBlob(file) {
return new Promise((resolve) => {
@@ -58,10 +79,24 @@ const fileOps = {
});
},
// FIXME: prefer exceptions for errors
/**
* @typedef {Object} UploadAnswer
* @property {boolean} ok
* @property {any} [data]
* @property {string} [errorMsg]
* @property {boolean} [isQuotaError]
* @property {boolean} [isTimeout]
*/
/**
* Upload a single file via XMLHttpRequest with progress events.
* Progress is reported to the notification bell via batchId + fileName.
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
* @param {FormData} formData
* @param {string} batchId
* @param {string} fileName
* @param {number} [timeoutMs=120000]
*/
_uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) {
return new Promise((resolve) => {
@@ -76,9 +111,16 @@ const fileOps = {
let lastProgressPctSent = -1;
let isSettled = false;
/** @type {ReturnType<typeof setTimeout>} */
let stallTimer = null;
/** @type {ReturnType<typeof setTimeout>} */
let hardTimer = null;
/**
*
* @param {number} pct
* @param {'uploading' | 'done' | 'error'} status
*/
const safeUpdateFile = (pct, status) => {
if (!notif || !batchId) return;
try {
@@ -88,6 +130,11 @@ const fileOps = {
}
};
/**
*
* @param {UploadAnswer} result
* @returns
*/
const finalize = (result) => {
if (isSettled) return;
isSettled = true;
@@ -219,6 +266,12 @@ const fileOps = {
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
*/
/**
*
* @param {*} formData
* @param {*} timeoutMs
* @returns {Promise<UploadAnswer>}
*/
async _uploadFileFetch(formData, timeoutMs = 60000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -254,11 +307,13 @@ const fileOps = {
const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507;
return { ok: false, errorMsg, isQuotaError };
} catch (e) {
const isTimeout = e?.name === 'AbortError';
const isTimeout = /** @type {Error} */ (e)?.name === 'AbortError';
return {
ok: false,
isTimeout,
errorMsg: isTimeout ? `Timeout after ${Math.round(timeoutMs / 1000)}s` : `Fetch upload failed: ${e?.message || 'network error'}`
errorMsg: isTimeout
? `Timeout after ${Math.round(timeoutMs / 1000)}s`
: `Fetch upload failed: ${/** @type {Error} */ (e)?.message || 'network error'}`
};
} finally {
clearTimeout(timeoutId);
@@ -458,6 +513,7 @@ const fileOps = {
try {
// Filter unreadable entries
/** @type {Array<{file: File, relativePath: string}>} */
const validEntries = [];
for (const e of rawEntries) {
// eslint-disable-next-line no-await-in-loop
@@ -560,12 +616,18 @@ const fileOps = {
const TIMEOUT_MIN_MS = 10000; // floor for tiny files
const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files
/**
*
* @param {number} idx
* @returns
*/
const uploadOneFile = async (idx) => {
if (quotaStop) return;
const entry = validEntries[idx];
const file = entry.file;
const rel = entry.relativePath || file.name;
/** @type {UploadAnswer} */
let result = { ok: false, errorMsg: 'Unknown client error' };
try {
const parts = rel.split('/');
@@ -577,6 +639,7 @@ const fileOps = {
// but block on open(). Pre-read only 0-byte files into
// memory; files with size>0 are always regular files and
// go straight to FormData (zero extra memory copy).
/** @type {Blob} */
let uploadFile = file; // default: use original File
if (file.size === 0) {
try {
@@ -616,7 +679,7 @@ const fileOps = {
} catch (e) {
result = {
ok: false,
errorMsg: `Client exception: ${e?.message || 'unknown'}`
errorMsg: `Client exception: ${/** @type {Error} */ (e)?.message || 'unknown'}`
};
console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e);
}
@@ -823,12 +886,6 @@ const fileOps = {
}
},
/**
* @typedef {Object} BatchResult
* @property {number} success number of files|folders sucessfully updated
* @property {number} errors number of files|folders in error
* /
/**
* Move files & folders
* @param {string[]} fileIds - File IDs
@@ -941,18 +998,12 @@ const fileOps = {
return res.ok;
},
/**
* @typedef {Object} BatchCopyReturn
* @property {number} success
* @property {number} errors
*/
/**
* Copy files & folders
* @param {string[]} fileIds - File IDs
* @param {string[]} folderIds - Folder IDs
* @param {string} targetFolderId - Target folder ID
* @returns {Promise<BatchCopyReturn>} - Success status
* @returns {Promise<BatchResult>} - Success status
*/
async batchCopy(fileIds, folderIds, targetFolderId) {
// FIXME ensure not moving a folder into itself
@@ -1010,7 +1061,6 @@ const fileOps = {
* Rename a file
* @param {string} fileId - File ID
* @param {string} newName - New file name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/
async renameFile(fileId, newName) {
try {
@@ -1052,13 +1102,6 @@ const fileOps = {
* Rename a folder
* @param {string} folderId - Folder ID
* @param {string} newName - New folder name
* @returns {Promise<boolean>} - Success status
*/
/**
* Rename a folder
* @param {string} folderId - Folder ID
* @param {string} newName - New folder name
* @returns {Promise<string|null>} - null on success, error message string on failure
*/
async renameFolder(folderId, newName) {
try {
@@ -1170,7 +1213,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) {
app.currentPath = '';
ui.updateBreadcrumb('');
ui.updateBreadcrumb();
}
loadFiles();
ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`);
@@ -1186,7 +1229,7 @@ const fileOps = {
// If we're inside the folder we just deleted, go back up
if (app.currentPath === folderId) {
app.currentPath = '';
ui.updateBreadcrumb('');
ui.updateBreadcrumb();
}
loadFiles();
ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`);
@@ -1205,7 +1248,7 @@ const fileOps = {
/**
* Get trash items
* @returns {Promise<Array>} - List of trash items
* @returns {Promise<Array<TrashItem>>} - List of trash items
*/
async getTrashItems() {
try {
@@ -1214,7 +1257,7 @@ const fileOps = {
});
if (response.ok) {
return await response.json();
return /** @type {TrashItem[]} */ (await response.json());
} else {
console.error('Error fetching trash items:', response.statusText);
return [];
+64 -32
View File
@@ -8,6 +8,8 @@ import { app } from '../../app/state.js';
import { isTextViewable } from '../../core/formatters.js';
import { wopiEditor } from './wopiEditor.js';
/** @import {FileItem} from '../../core/types.js' */
class InlineViewer {
constructor() {
this.setupViewer();
@@ -93,6 +95,11 @@ class InlineViewer {
console.log('Inline viewer initialized');
}
/**
*
* @param {FileItem} file
* @returns
*/
async openFile(file) {
console.log('Opening file:', file);
@@ -115,7 +122,7 @@ class InlineViewer {
// Get container
const modal = document.getElementById('inline-viewer-modal');
const container = modal.querySelector('.inline-viewer-container');
const container = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-container'));
const title = modal.querySelector('.inline-viewer-title');
// Clear container
@@ -125,12 +132,12 @@ class InlineViewer {
title.textContent = file.name;
// Set controls visibility
const controls = modal.querySelector('.inline-viewer-controls');
const controls = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-controls'));
// Show viewer based on file type
if (isImage) {
// Show zoom controls
controls.style.display = 'flex';
controls.classList.remove('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -142,7 +149,7 @@ class InlineViewer {
this.createBlobUrlViewer(file, 'image', container, loader);
} else if (file.mime_type && file.mime_type === 'application/pdf') {
// Hide zoom controls for PDFs
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -152,9 +159,9 @@ class InlineViewer {
// Create PDF viewer using object tag with blob URL
this.createBlobUrlViewer(file, 'pdf', container, loader);
} else if (file.mime_type && this.isTextViewable(file.mime_type)) {
} else if (file.mime_type && isTextViewable(file.mime_type)) {
// Hide zoom controls for text files
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -166,7 +173,7 @@ class InlineViewer {
this.createTextViewer(file, container, loader);
} else if (file.mime_type?.startsWith('audio/')) {
// Hide zoom controls for audio
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -178,7 +185,7 @@ class InlineViewer {
this.createMediaViewer(file, 'audio', container, loader);
} else if (file.mime_type?.startsWith('video/')) {
// Hide zoom controls for video
controls.style.display = 'none';
controls.classList.add('hidden');
// Show loading indicator
const loader = document.createElement('div');
@@ -190,7 +197,7 @@ class InlineViewer {
this.createMediaViewer(file, 'video', container, loader);
} else {
// Hide zoom controls for unsupported files
controls.style.display = 'none';
controls.classList.add('hidden');
// Show unsupported file message
const message = document.createElement('div');
@@ -209,12 +216,13 @@ class InlineViewer {
modal.classList.add('active');
}
// Check if a MIME type is text-viewable
isTextViewable(mimeType) {
return isTextViewable(mimeType);
}
// Creates a text viewer using authenticated fetch
/**
*
* @param {FileItem} file
* @param {HTMLDivElement} container
* @param {*} loader
*/
async createTextViewer(file, container, loader) {
try {
console.log('Creating text viewer for:', file.name);
@@ -253,14 +261,20 @@ class InlineViewer {
}
}
// Creates a viewer using a Blob URL to avoid content-disposition header
async createBlobUrlViewer(file, type, container, loader) {
/**
* Creates a viewer using a Blob URL to avoid content-disposition header
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createBlobUrlViewer(file, mediaType, container, loader) {
try {
console.log('Creating blob URL viewer for:', file.name, 'type:', type);
console.log('Creating blob URL viewer for:', file.name, 'type:', mediaType);
// Update loader to show progress bar for large files
let progressBar = null;
let progressText = null;
let progressBar = /** @type {HTMLElement|null} */ (null);
let progressText = /** @type {HTMLElement|null} */ (null);
if (loader && file.size > 10 * 1024 * 1024) {
// Show progress for files > 10MB
loader.innerHTML = `
@@ -272,8 +286,8 @@ class InlineViewer {
<div class="inline-viewer-progress-text">0%</div>
</div>
`;
progressBar = loader.querySelector('.inline-viewer-progress-fill');
progressText = loader.querySelector('.inline-viewer-progress-text');
progressBar = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-fill'));
progressText = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-text'));
}
// Use XMLHttpRequest instead of fetch to get better control over the response
@@ -322,7 +336,7 @@ class InlineViewer {
loader.parentNode.removeChild(loader);
}
if (type === 'image') {
if (mediaType === 'image') {
console.log('Creating image viewer');
// Create image element
const img = document.createElement('img');
@@ -332,10 +346,10 @@ class InlineViewer {
container.appendChild(img);
// Add loading indicator until image loads
img.style.opacity = 0;
img.style.opacity = String(0);
img.onload = () => {
console.log('Image loaded successfully');
img.style.opacity = 1;
img.style.opacity = String(1);
};
img.onerror = () => {
@@ -343,7 +357,7 @@ class InlineViewer {
container.removeChild(img);
this.showErrorMessage(container);
};
} else if (type === 'pdf') {
} else if (mediaType === 'pdf') {
console.log('Creating PDF viewer');
// Create iframe for PDF (more reliable than object tag)
@@ -382,7 +396,13 @@ class InlineViewer {
}
}
// Creates an audio or video player using blob URL (authenticated fetch)
/**
* Creates an audio or video player using blob URL (authenticated fetch)
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createMediaViewer(file, mediaType, container, loader) {
try {
console.log(`Creating ${mediaType} player for:`, file.name);
@@ -485,7 +505,10 @@ class InlineViewer {
}
}
// Helper to show error message
/**
* Helper to show error message
* @param {HTMLDivElement} container
*/
showErrorMessage(container) {
// Show error message
const message = document.createElement('div');
@@ -505,7 +528,7 @@ class InlineViewer {
const modal = document.getElementById('inline-viewer-modal');
// stops audio/video before closing viewver
const media = modal.querySelector('audio, video');
const media = /** @type {HTMLMediaElement} */ (modal.querySelector('audio, video'));
if (media && !media.paused) media.pause();
// Hide modal
@@ -525,6 +548,10 @@ class InlineViewer {
this.currentFile = null;
}
/**
*
* @param {FileItem} file
*/
downloadFile(file) {
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
.then((res) => {
@@ -544,9 +571,14 @@ class InlineViewer {
.catch((err) => console.error('Download error:', err));
}
/**
*
* @param {number} factor
* @returns
*/
zoomImage(factor) {
const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image');
const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return;
@@ -560,7 +592,7 @@ class InlineViewer {
scale = Math.max(0.1, Math.min(5.0, scale));
// Save scale
img.dataset.scale = scale;
img.dataset.scale = String(scale);
// Apply scale
img.style.transform = `scale(${scale})`;
@@ -568,12 +600,12 @@ class InlineViewer {
resetZoom() {
const container = document.querySelector('.inline-viewer-container');
const img = container.querySelector('.inline-viewer-image');
const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
if (!img) return;
// Reset scale
img.dataset.scale = 1.0;
img.dataset.scale = String(1);
img.style.transform = 'scale(1.0)';
}
}
+69 -14
View File
@@ -9,8 +9,6 @@
// TODO: rename into selection-bar ?
// TODO: merge with photo part
// @ts-check
import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js';
@@ -19,8 +17,14 @@ import { favorites } from '../library/favorites.js';
import { contextMenus } from './contextMenus.js';
import { getAuthHeaders } from './fileOperations.js';
/**
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
* @import {BatchResult} from './fileOperations.js'
*/
const multiSelect = {
/** Currently selected items: Map<id, { id, name, type, parentId }> */
/** @type {Map<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
_selected: new Map(),
/** Last clicked index for Shift-range selection */
@@ -49,6 +53,12 @@ const multiSelect = {
// ── Helpers for i18n ────────────────────────────────────
/**
*
* @param {string} key
* @param {any} vars
* @returns
*/
_t(key, vars) {
const val = i18n.t(key, vars);
return val !== key ? val : null;
@@ -56,6 +66,14 @@ const multiSelect = {
// ── Selection state management ──────────────────────────
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
toggle(id, name, type, parentId) {
if (this._selected.has(id)) {
this._selected.delete(id);
@@ -65,10 +83,22 @@ const multiSelect = {
return true;
},
/**
*
* @param {string} id
* @param {string} name
* @param {ItemTypeEnum} type
* @param {string} parentId
* @returns
*/
select(id, name, type, parentId) {
this._selected.set(id, { id, name, type, parentId });
},
/**
*
* @param {string} id
*/
deselect(id) {
this._selected.delete(id);
},
@@ -80,7 +110,7 @@ const multiSelect = {
el.classList.remove('selected');
});
document.querySelectorAll('.item-checkbox').forEach((cb) => {
cb.checked = false;
/** @type {HTMLInputElement} */ (cb).checked = false;
});
this._syncUI();
},
@@ -111,11 +141,13 @@ const multiSelect = {
* @return {ItemSelection}
*/
getSelection(targtFolderId) {
/** @type {Array<string>} */
const fileIds = [];
/** @type {Array<string>} */
const folderIds = [];
// TODO optimize & check if _selected is a better use
document.querySelectorAll(`div.file-item.selected`).forEach((item) => {
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
if (item.dataset.fileId) {
fileIds.push(item.dataset.fileId);
} else {
@@ -152,6 +184,10 @@ const multiSelect = {
// ── DOM helpers ─────────────────────────────────────────
/**
*
* @param {HTMLDivElement} el
*/
_selectElement(el) {
const info = this._extractInfo(el);
if (info) {
@@ -160,18 +196,32 @@ const multiSelect = {
}
},
/**
*
* @param {string} containerId
* @param {string} selector
* @returns {void}
*/
_selectAllInContainer(containerId, selector) {
const container = document.getElementById(containerId);
const container = /** @type {HTMLDivElement} */ (document.getElementById(containerId));
if (!container) return;
container.querySelectorAll(selector).forEach((el) => {
/** @type {NodeListOf<HTMLDivElement>} */ (container.querySelectorAll(selector)).forEach((el) => {
this._selectElement(el);
});
},
/**
*
* @returns {HTMLDivElement[]}
*/
_getAllVisibleItems() {
return [...document.querySelectorAll('.file-item')];
return /** @type {HTMLDivElement[]} */ ([...document.querySelectorAll('.file-item')]);
},
/**
* @param {HTMLDivElement} el
* @returns {LightItem}
*/
_extractInfo(el) {
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
return {
@@ -194,6 +244,10 @@ const multiSelect = {
// ── Click handler (shared by grid + list) ───────────────
/**
* @param {HTMLDivElement} el
* @param {MouseEvent} event
*/
handleToggleItem(el, event) {
const items = this._getAllVisibleItems();
const index = items.indexOf(el);
@@ -210,7 +264,7 @@ const multiSelect = {
const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`;
document.querySelectorAll(sel).forEach((e) => {
e.classList.add('selected');
const checkbox = e.querySelector('input[type="checkbox"]');
const checkbox = /** @type {HTMLInputElement} */ (e.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = true;
});
}
@@ -218,7 +272,7 @@ const multiSelect = {
} else {
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
el.classList.toggle('selected', nowSelected);
const checkbox = el.querySelector('input[type="checkbox"]');
const checkbox = /** @type {HTMLInputElement} */ (el.querySelector('input[type="checkbox"]'));
if (checkbox) checkbox.checked = nowSelected;
}
this._lastClickedIndex = index;
@@ -271,13 +325,13 @@ const multiSelect = {
_syncItemCheckboxes() {
document.querySelectorAll('.file-item').forEach((el) => {
const cb = el.querySelector('.item-checkbox');
const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
if (cb) cb.checked = el.classList.contains('selected');
});
},
_syncSelectAllCheckbox() {
const cb = document.getElementById('select-all-checkbox');
const cb = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
if (!cb) return;
const all = this._getAllVisibleItems();
if (all.length === 0) {
@@ -454,9 +508,10 @@ const multiSelect = {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
const target = /** @type {Element} */ (e.target);
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
// ctrl+a cmd+a
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
if (selectAllCheckbox) selectAllCheckbox.checked = true;
+30 -18
View File
@@ -12,6 +12,10 @@ import { app } from '../../app/state.js';
import { ui } from '../../app/ui.js';
import { getAuthHeaders } from './fileOperations.js';
/**
* @import {SearchCriteria, SearchResults} from '../../core/types.js'}
*/
const search = {
/**
* Perform a search using query parameters.
@@ -19,25 +23,29 @@ const search = {
* relevance_score, icon_class, category, size_formatted, etc.
*
* @param {string} query - Search query
* @param {Object} options - Additional search options
* @returns {Promise<Object>} - Enriched search results from backend
* @param {SearchCriteria} [options] - Additional search options
* @returns {Promise<SearchResults>} - Enriched search results from backend
*/
async searchFiles(query, options = {}) {
async searchFiles(query, options) {
try {
const params = new URLSearchParams();
params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.recursive !== undefined) params.append('recursive', options.recursive);
if (options.file_types) params.append('type', options.file_types);
if (options.min_size) params.append('min_size', options.min_size);
if (options.max_size) params.append('max_size', options.max_size);
if (options.created_after) params.append('created_after', options.created_after);
if (options.created_before) params.append('created_before', options.created_before);
if (options.modified_after) params.append('modified_after', options.modified_after);
if (options.modified_before) params.append('modified_before', options.modified_before);
if (options.limit) params.append('limit', options.limit);
if (options.offset) params.append('offset', options.offset);
if (options.recursive !== undefined) params.append('recursive', String(options.recursive));
if (options.file_types) {
options.file_types.forEach((file_type) => {
params.append('type', file_type);
});
}
if (options.min_size) params.append('min_size', String(options.min_size));
if (options.max_size) params.append('max_size', String(options.max_size));
if (options.created_after) params.append('created_after', String(options.created_after));
if (options.created_before) params.append('created_before', String(options.created_before));
if (options.modified_after) params.append('modified_after', String(options.modified_after));
if (options.modified_before) params.append('modified_before', String(options.modified_before));
if (options.limit) params.append('limit', String(options.limit));
if (options.offset) params.append('offset', String(options.offset));
if (options.sort_by) params.append('sort_by', options.sort_by);
const url = `/api/search?${params.toString()}`;
@@ -46,6 +54,7 @@ const search = {
const response = await fetch(url, { headers: getAuthHeaders() });
if (response.ok) {
/** @type {SearchResults} */
return await response.json();
} else {
let errorText = '';
@@ -66,7 +75,10 @@ const search = {
folders: [],
total_count: 0,
query_time_ms: 0,
sort_by: 'relevance'
sort_by: 'relevance',
limit: 0,
offset: 0,
has_more: false
};
}
},
@@ -76,15 +88,15 @@ const search = {
* Returns lightweight name suggestions without full search overhead.
*
* @param {string} query - Prefix to search for
* @param {Object} options - { folder_id, limit }
* @param {SearchCriteria} [options] - { folder_id, limit }
* @returns {Promise<Object>} - { suggestions: [...], query_time_ms }
*/
async getSuggestions(query, options = {}) {
async getSuggestions(query, options) {
try {
const params = new URLSearchParams();
params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.limit) params.append('limit', options.limit);
if (options.limit) params.append('limit', String(options.limit));
const url = `/api/search/suggest?${params.toString()}`;
const response = await fetch(url, { headers: getAuthHeaders() });
@@ -111,7 +123,7 @@ const search = {
* - query_time_ms: Server-side query execution time
* - sort_by: Active sort order
*
* @param {Object} results - Enriched search results from backend
* @param {SearchResults} results - Enriched search results from backend
*/
displaySearchResults(results) {
ui.resetFilesList(); // ensure also list visible & error hidden
+23 -4
View File
@@ -19,6 +19,7 @@ class WopiEditor {
/**
* Check if a file can be opened in a WOPI editor by extension.
* Fetches supported extensions from the server (cached after first call).
* @param {string} filename
*/
async canEdit(filename) {
const ext = filename.split('.').pop().toLowerCase();
@@ -28,6 +29,9 @@ class WopiEditor {
/**
* Open file in a modal overlay (default mode).
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/
async openInModal(fileId, fileName, action) {
action = action || 'edit';
@@ -38,6 +42,9 @@ class WopiEditor {
/**
* Open file in a new browser tab.
* @param {string} fileId
* @param {string} fileName
* @param {string} [action]
*/
async openInTab(fileId, fileName, action) {
action = action || 'edit';
@@ -53,6 +60,8 @@ class WopiEditor {
/**
* Fetch editor URL and WOPI token from the backend.
* @param {string} fileId
* @param {string} action
*/
async _getEditorUrl(fileId, action) {
const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, {
@@ -68,6 +77,9 @@ class WopiEditor {
/**
* Some WOPI file types, such as PDFs, are view-only.
* If an edit request returns 422, retry once in view mode.
* @param {string} fileId
* @param {string} fileName
* @param {string} action
*/
async _getEditorUrlWithFallback(fileId, fileName, action) {
try {
@@ -81,6 +93,11 @@ class WopiEditor {
}
}
/**
* @param {string} fileName
* @param {string} action
* @param {any} error
*/
_shouldRetryInViewMode(fileName, action, error) {
if (action !== 'edit' || !error || !error.message) {
return false;
@@ -92,6 +109,8 @@ class WopiEditor {
/**
* Show the editor in a full-screen modal with iframe.
* @param {Record<string, any>} editorData
* @param {string} fileName
*/
_showModal(editorData, fileName) {
this.closeEditor();
@@ -160,13 +179,13 @@ class WopiEditor {
document.body.appendChild(modal);
// ESC key handler
this._escHandler = function (e) {
this._escHandler = (/** @type {KeyboardEvent} */ e) => {
if (e.key === 'Escape') this.closeEditor();
}.bind(this);
};
document.addEventListener('keydown', this._escHandler);
// Fix 7: Listen for postMessage from the editor iframe
this._messageHandler = function (e) {
this._messageHandler = (/** @type {MessageEvent} */ e) => {
var data;
try {
data = JSON.parse(e.data);
@@ -183,7 +202,7 @@ class WopiEditor {
if (sp) sp.remove();
}
}
}.bind(this);
};
window.addEventListener('message', this._messageHandler);
form.submit();