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
+10
View File
@@ -8,6 +8,14 @@ import { updateStorageUsageDisplay } from './main.js';
import { app } from './state.js';
import { ui } from './ui.js';
/**
* @import {User} from '../core/types.js'
*/
/**
*
* @returns {Promise<User | null>}
*/
async function refreshUserData() {
const USER_DATA_KEY = 'oxicloud_user';
@@ -25,6 +33,7 @@ async function refreshUserData() {
return null;
}
/** @type {User} */
const userData = await response.json();
console.log('Refreshed user data from server:', userData);
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
@@ -83,6 +92,7 @@ async function checkAuthentication() {
// Check session validity by calling /api/auth/me (cookie auto-sent)
console.log('Checking session via /api/auth/me...');
/** @type {User} */
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
if (userData.username) {
// We have cached user data — render immediately, refresh in background
+10 -6
View File
@@ -9,14 +9,14 @@ import { app } from './state.js';
import { ui } from './ui.js';
import { uiNotifications } from './uiNotifications.js';
/** @import {FileInfo, FolderInfo} from '../core/types.js' */
/** @import {FileItem, FolderItem} from '../core/types.js' */
let isLoadingFiles = false;
/**
* getFolder information
* @param {string} id the id of the folder
* @returns {Promise<FolderInfo>}
* @returns {Promise<FolderItem>}
*/
async function getFolder(id) {
/** @type {HeadersInit} */
@@ -47,7 +47,7 @@ async function getFolder(id) {
async function rebuildBreadCrumb() {
/**
* Store the leaf (this is the current displayed folder)
* @type {FolderInfo | null}
* @type {FolderItem | null}
*/
let currentFolderInfo = null;
@@ -172,7 +172,11 @@ async function loadFiles(options = { insertHistory: true }) {
if (forceRefresh) {
url += `&force_refresh=true`;
if (requestOptions.headers) requestOptions.headers['X-Force-Refresh'] = 'true';
if (requestOptions.headers) {
const headers = new Headers(requestOptions.headers);
headers.set('X-Force-Refresh', 'true');
requestOptions.headers = headers;
}
console.log('Forcing complete refresh ignoring cache');
}
@@ -202,10 +206,10 @@ async function loadFiles(options = { insertHistory: true }) {
multiSelect.init(); // this will wire buttons & select-all-checkbox
}
/** @type {FolderInfo[]} */
/** @type {FolderItem[]} */
const folderList = Array.isArray(listing.folders) ? listing.folders : [];
/** @type {FileInfo[]} */
/** @type {FileItem[]} */
const fileList = Array.isArray(listing.files) ? listing.files : [];
if (folderList.length === 0 && fileList.length === 0) {
+7 -2
View File
@@ -35,6 +35,10 @@ import { loadTrashItems } from './trashView.js';
import { ui } from './ui.js';
import { setupUserMenu } from './userMenu.js';
/**
* @import {User} from '../core/types.js'
*/
// Upload dropdown listener state (prevents accumulated listeners)
/** @type {((e: MouseEvent) => void) | null} */
let uploadDropdownDocumentClickHandler = null;
@@ -141,7 +145,7 @@ const ACTIONS_BAR_TEMPLATES = {
/**
*
* @param {string} mode
* @param {'files' | 'trash' | 'favorites' | 'recent' | 'hidden'} mode
* @param {boolean} [force=false]
* @returns
*/
@@ -494,6 +498,7 @@ function setupEventListeners() {
ui.setupDragAndDrop();
// Debounce timer for live search
/** @type {ReturnType<typeof setTimeout>} */
let searchDebounceTimer = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 3;
@@ -726,7 +731,7 @@ export function selectFolder(id, name) {
/**
* Update the storage usage display with the user's actual storage usage
* @param {Object} userData - The user data object
* @param {User} userData - The user data object
*/
function updateStorageUsageDisplay(userData) {
// Default values
-6
View File
@@ -136,12 +136,6 @@ export const SECTIONS_MAPPER = {
*/
function setCurrentSection(section) {
if (app.currentSection === section) return false;
// Set all view flags - true for active section, false for others
Object.entries(SECTIONS_MAPPER).forEach(([key, flag]) => {
app[flag] = key === section;
});
app.currentSection = section;
// Update nav item active classes by finding matching item from DOM
+11 -3
View File
@@ -8,9 +8,14 @@ import { app } from './state.js';
import { ui } from './ui.js';
/**
* @param {string} query
* @param {string} [sortBy]
* @import {SearchCriteria, SortByEnnum} from '../core/types.js'
*/
/**
* @param {string} query
* @param {SortByEnnum} [sortBy]
*/
// FIXME: refactor with search.js ?
async function performSearch(query, sortBy) {
console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`);
@@ -20,9 +25,11 @@ async function performSearch(query, sortBy) {
ui.showError(`<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>`);
/** @type {SearchCriteria} */
const options = {
recursive: true,
limit: 100,
offset: 0,
sort_by: sortBy || 'relevance'
};
@@ -51,7 +58,8 @@ document.addEventListener('search-resort', (e) => {
const event = /** @type {CustomEvent<{sort_by: string}>} */ (e);
const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input'));
if (searchInput?.value.trim()) {
performSearch(searchInput.value.trim(), event.detail.sort_by);
const sortBy = /** @type {SortByEnnum} */ (event.detail.sort_by);
performSearch(searchInput.value.trim(), sortBy);
}
});
+37 -6
View File
@@ -3,39 +3,70 @@
* Centralized mutable state for app and cached DOM references.
*/
/** @import {FolderInfo} from '../core/types.js' */
/** @import {FileItem, FolderItem, LightItem} from '../core/types.js' */
export const app = {
currentView: 'grid',
/** @type {string | null} */
currentPath: '',
/** @type {string | null} */
currentFolder: null,
/** @type {FolderInfo | null} */
/** @type {FolderItem | null} */
currentFolderInfo: null,
/** @type {Object | null} */
/** @type {FolderItem | null} */
contextMenuTargetFolder: null,
/** @type {Object | null} */
/** @type {FileItem | null} */
contextMenuTargetFile: null,
selectedTargetFolderId: '',
moveDialogMode: 'file',
/** @type {string | null} */
moveDialogItemId: null,
/** @type {'file' | 'folder' | null} */
moveDialogItemMode: null,
/** @type {string | null} */
moveDialogCurrentFolderId: null,
/** @type {Array<{id: string, name: string}>} */
moveDialogBreadcrumb: [],
/** @type {FileItem[] | null} */
playlistDialogFiles: null,
/** @type {String | null} */
currentSection: null, // will be defined on first call
isSearchMode: false,
/** @type {FileItem | FolderItem | null} */
shareDialogItem: null,
/** @type {'file' | 'folder' | null} */
shareDialogItemType: null,
/** @type {String | null} */
notificationShareUrl: null,
/** @type {string | null} */
userHomeFolderId: null,
/** @type {string | null} */
userHomeFolderName: null,
/** @type {Object[]} */
/** @type {Array<{id: string, name: string}>} */
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
/** @type {String | null} */
viewFile: null // current file in inline view
viewFile: null, // current file in inline view
/** @type {LightItem[] | null} */
batchMoveItems: null
};
export const appElements = {
+10 -1
View File
@@ -9,6 +9,11 @@ import { multiSelect } from '../features/files/multiSelect.js';
import { appElements } from './state.js';
import { ui } from './ui.js';
/**
*
* @import {TrashItem} from '../core/types.js'
*/
async function loadTrashItems() {
const elements = appElements;
@@ -25,7 +30,7 @@ async function loadTrashItems() {
</div>
`;
ui.updateBreadcrumb('');
ui.updateBreadcrumb();
const trashItems = await fileOps.getTrashItems();
@@ -46,6 +51,10 @@ async function loadTrashItems() {
}
}
/**
*
* @param {TrashItem} item
*/
function addTrashItemToView(item) {
const elements = appElements;
const isFile = item.item_type === 'file';
+100 -51
View File
@@ -25,10 +25,17 @@ import { app } from './state.js';
import { uiFileTypes } from './uiFileTypes.js';
import { uiNotifications } from './uiNotifications.js';
/**
* @import {FileItem, FolderItem} from '../core/types.js'
* @import {BatchResult} from '../features/files/fileOperations.js'
*/
// UI Module
const ui = {
/** @type {HTMLDListElement | null} */
//dragPreview,
/** @type {HTMLDivElement | null} */
dragPreview: null,
/** @type {HTMLDivElement | null} */
draggedItems: null,
/**
* Initialize context menus and dialogs
@@ -338,21 +345,31 @@ const ui = {
const dropzone = document.getElementById('dropzone');
/**
*
* @param {DataTransfer} dataTransfer
* @returns {Promise<any[]|null>}
*/
const collectDroppedEntries = async (dataTransfer) => {
const items = Array.from(dataTransfer?.items || []);
const rootEntries = items.map((it) => (typeof it.webkitGetAsEntry === 'function' ? it.webkitGetAsEntry() : null)).filter(Boolean);
if (rootEntries.length === 0) return null;
/** @type {Array<{file: File, relativePath: string}>} */
const out = [];
/**
* @param {FileSystemEntry} entry
* @param {string} prefix
*/
const walkEntry = async (entry, prefix = '') => {
if (!entry) return;
if (entry.isFile) {
await new Promise((resolve) => {
entry.file(
(file) => {
/** @type {FileSystemFileEntry} */ (entry).file(
(/** @type {File} */ file) => {
out.push({ file, relativePath: `${prefix}${file.name}` });
resolve(undefined);
},
@@ -364,7 +381,7 @@ const ui = {
if (entry.isDirectory) {
const dirPrefix = `${prefix}${entry.name}/`;
const reader = entry.createReader();
const reader = /** @type {FileSystemDirectoryEntry} */ (entry).createReader();
while (true) {
const children = await new Promise((resolve) => {
@@ -636,7 +653,7 @@ const ui = {
/**
* Check if a file can be previewed in the viewer
* @param {Object} file - File object with mime_type property
* @param {FileItem} file
* @returns {boolean}
*/
isViewableFile(file) {
@@ -647,6 +664,7 @@ const ui = {
* Get FontAwesome icon class for a filename based on its extension.
* Used as fallback when the backend DTO doesn't include icon_class
* (e.g. trash items).
* @param {string} fileName
*/
getIconClass(fileName) {
return uiFileTypes.getIconClass(fileName);
@@ -655,6 +673,7 @@ const ui = {
/**
* Get CSS special class for icon styling based on filename extension.
* Used as fallback when the backend DTO doesn't include icon_special_class.
* @param {string} fileName
*/
getIconSpecialClass(fileName) {
return uiFileTypes.getIconSpecialClass(fileName);
@@ -695,13 +714,13 @@ const ui = {
* Data store + event delegation (replaces per-item listeners)
* ================================================================ */
/** @type {Map<string, Object>} item data keyed by id */
/** @type {Map<string, FolderItem | FileItem>} item data keyed by id */
_items: new Map(),
/** @type {Array<Object>} last rendered folder dataset */
/** @type {FolderItem[]} last rendered folder dataset */
_lastFolders: [],
/** @type {Array<Object>} last rendered file dataset */
/** @type {FileItem[]} last rendered file dataset */
_lastFiles: [],
/** @type {boolean} */
@@ -716,9 +735,7 @@ const ui = {
},
/**
*
* @param {Object[]} folders
* @returns
* @param {FolderItem[]} folders
*/
_renderFoldersToView(folders) {
if (!Array.isArray(folders) || folders.length === 0) return;
@@ -733,9 +750,7 @@ const ui = {
},
/**
*
* @param {Object[]} files
* @returns
* @param {FileItem[]} files
*/
_renderFilesToView(files) {
if (!Array.isArray(files) || files.length === 0) return;
@@ -749,6 +764,10 @@ const ui = {
target.appendChild(frag);
},
/**
* @param {any[]} arr
* @param {any} item
*/
_upsertById(arr, item) {
if (!Array.isArray(arr) || !item?.id) return;
const idx = arr.findIndex((x) => x && x.id === item.id);
@@ -796,6 +815,7 @@ const ui = {
await fileOps.moveFile(sourceId, targetFolderId);
*/
/** @type {BatchResult} */
let result;
switch (action) {
case 'copy':
@@ -843,6 +863,7 @@ const ui = {
this._delegationReady = true;
// ── helpers ────────────────────────────────────────────────
/** @param {HTMLDivElement} card */
const itemInfo = (card) => {
if (!card) return null;
const fileId = card.dataset.fileId;
@@ -864,6 +885,7 @@ const ui = {
return null;
};
/** @param {FileItem} file */
const openFile = async (file) => {
if (!file) return;
if (recent) {
@@ -897,6 +919,7 @@ const ui = {
}
};
/** @param {HTMLElement} card */
const navigateFolder = (card) => {
const folderId = card.dataset.folderId;
const folderName = card.dataset.folderName;
@@ -912,27 +935,31 @@ const ui = {
loadFiles();
};
/**
* @param {HTMLElement} card
* @param {{ type: string, id: string, name: string | undefined, data: FolderItem | FileItem | undefined }} info
*/
const setContextTarget = (card, info) => {
if (info.type === 'folder') {
app.contextMenuTargetFolder = {
app.contextMenuTargetFolder = /** @type {FolderItem} */ ({
id: info.id,
name: card.dataset.folderName,
parent_id: card.dataset.parentId || ''
};
});
} else {
const fileData = info.data || this._items.get(info.id);
app.contextMenuTargetFile = {
const fileData = /** @type {FileItem | undefined} */ (info.data || this._items.get(info.id));
app.contextMenuTargetFile = /** @type {FileItem} */ ({
id: info.id,
name: card.dataset.fileName,
folder_id: card.dataset.folderId || '',
mime_type: fileData?.mime_type || null
};
});
}
};
// ── click (open / navigate; select only via checkbox) ──
filesList.addEventListener('click', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return;
if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) {
@@ -975,7 +1002,7 @@ const ui = {
if (info.type === 'folder') {
navigateFolder(card);
} else {
openFile(info.data);
openFile(/** @type {FileItem} */ (info.data));
}
});
@@ -989,7 +1016,7 @@ const ui = {
// ── shared events ──────────────────────
filesList.addEventListener('contextmenu', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) return;
e.preventDefault();
const info = itemInfo(card);
@@ -1008,7 +1035,7 @@ const ui = {
// dragstart
filesList.addEventListener('dragstart', (e) => {
const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
const card = /** @type {HTMLDivElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item'));
if (!card) {
e.preventDefault();
return;
@@ -1088,9 +1115,11 @@ const ui = {
// TODO better naming like ("selection in ${parent.name}") modulo i18n ? ...
const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-');
nameEncoded = `oxicloud ${now}.zip`;
/** @type {string[]} */
const folders = [];
/** @type {string[]} */
const files = [];
filesList.querySelectorAll(`div.selected`).forEach((e) => {
/** @type {NodeListOf<HTMLDivElement>} */ (filesList.querySelectorAll(`div.selected`)).forEach((e) => {
const item = itemInfo(e);
if (item.type === 'file') {
files.push(item.id);
@@ -1164,6 +1193,7 @@ const ui = {
* Favorite star helper – attaches a direct click handler to a
* star <button> so the event never bubbles to the card.
* ================================================================ */
/** @param {HTMLElement} el */
_bindStarClick(el) {
const star = el.querySelector('.favorite-star');
star?.addEventListener('click', (e) => {
@@ -1174,7 +1204,8 @@ const ui = {
if (!favorites) return;
// FIXME: make a function
const itemElement = shared?.closest('.file-item');
const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
@@ -1187,7 +1218,7 @@ const ui = {
favorites.removeFromFavorites(itemId, itemType);
} else {
this.setFavoriteVisualState(itemId, itemType, true);
favorites.addToFavorites(itemId, itemName, itemType);
favorites.addToFavorites(itemId, itemName, itemType, null);
}
// Keep context-menu label in sync if available
@@ -1201,26 +1232,30 @@ const ui = {
e.preventDefault();
// FIXME: make a function
const itemElement = shared?.closest('.file-item');
const itemElement = /** @type {HTMLElement | null} */ (shared?.closest('.file-item'));
if (!itemElement) return;
const itemId = itemElement.dataset.fileId ? itemElement.dataset.fileId : itemElement.dataset.folderId;
const itemType = itemElement.dataset.fileId ? 'file' : 'folder';
const itemName = itemElement.dataset.fileId ? itemElement.dataset.fileName : itemElement.dataset.folderName;
// TODO corrently dirty
const item = {
const item = /** @type {unknown} */ ({
id: itemId,
item_id: itemId,
item_type: itemType,
item_name: itemName
};
});
contextMenus.showShareDialog(item, itemType);
contextMenus.showShareDialog(/** @type {FileItem} */ (item), itemType);
});
},
/**
* Sync favorite visuals for a file/folder across grid and list views.
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isFavorite
*/
setFavoriteVisualState(itemId, itemType, isFavorite) {
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1258,6 +1293,11 @@ const ui = {
}
},
/**
* @param {string} itemId
* @param {string} itemType
* @param {boolean} isShared
*/
setSharedVisualState(itemId, itemType, isShared) {
console.log(`setSharedVisual call for ${itemId} ${itemType} to ${isShared}`);
const selector = itemType === 'folder' ? `#files-list .file-item[data-folder-id="${itemId}"]` : `#files-list .file-item[data-file-id="${itemId}"]`;
@@ -1273,7 +1313,10 @@ const ui = {
* Element-creation helpers
* ================================================================ */
/** Create a list row for a folder */
/**
* Create a list row for a folder
* @param {FolderItem} folder
*/
_createFolderItem(folder) {
const el = document.createElement('div');
el.className = 'file-item';
@@ -1314,7 +1357,10 @@ const ui = {
return el;
},
/** Create a grid card for a file */
/**
* Create a grid card for a file
* @param {FileItem} file
*/
_createFileItem(file) {
const iconClass = file.icon_class || this.getIconClass(file.name);
const iconSpecialClass = file.icon_special_class || this.getIconSpecialClass(file.name);
@@ -1425,7 +1471,7 @@ const ui = {
* Render an array of folders into both grid and list views
* using DocumentFragment for minimal reflows.
*
* @param {FolderInfo[]} folders
* @param {FolderItem[]} folders
*/
renderFolders(folders) {
if (!this._delegationReady) this.initDelegation();
@@ -1442,6 +1488,7 @@ const ui = {
/**
* Render an array of files into both grid and list views
* using DocumentFragment for minimal reflows.
* @param {FileItem[]} files
*/
renderFiles(files) {
if (!this._delegationReady) this.initDelegation();
@@ -1461,7 +1508,7 @@ const ui = {
/**
* Add a single folder to the active view.
* @param {Object} folder - Folder object
* @param {FolderItem} folder
*/
addFolderToView(folder) {
if (!this._delegationReady) this.initDelegation();
@@ -1479,7 +1526,7 @@ const ui = {
/**
* Add a single file to the active view.
* @param {Object} file - File object
* @param {FileItem} file
*/
addFileToView(file) {
if (!this._delegationReady) this.initDelegation();
@@ -1501,6 +1548,8 @@ const ui = {
/**
* Toggle selection state of a file/folder card.
* Routes through the multiSelect module so batch actions know about selected items.
* @param {HTMLDivElement} card
* @param {MouseEvent} event
*/
function toggleCardSelection(card, event) {
if (multiSelect) {
@@ -1512,6 +1561,8 @@ function toggleCardSelection(card, event) {
/**
* Show the context menu anchored next to a trigger element (the 3-dot button).
* @param {HTMLElement} triggerElement
* @param {string} menuId
*/
function showContextMenuAtElement(triggerElement, menuId) {
// Hide any open menus first
@@ -1542,8 +1593,6 @@ function showContextMenuAtElement(triggerElement, menuId) {
menu.classList.remove('hidden');
}
let __rubberBandJustFinished = false;
/**
* Rubber band (lasso) selection — click + drag on empty grid area
* to draw a rectangle and select all cards it touches.
@@ -1567,16 +1616,18 @@ function initRubberBandSelection() {
if (!container) return;
container.addEventListener('mousedown', (e) => {
if (!(e instanceof MouseEvent)) return;
// Only start if clicking empty area (not on a card, button, menu, input…)
if (e.button !== 0) return; // left click only
const target = /** @type {Element} */ (e.target);
if (
e.target.closest('.file-item') ||
e.target.closest('.context-menu') ||
e.target.closest('.upload-dropdown') ||
e.target.closest('button') ||
e.target.closest('input') ||
e.target.closest('.breadcrumb') ||
e.target.closest('.list-header')
target.closest('.file-item') ||
target.closest('.context-menu') ||
target.closest('.upload-dropdown') ||
target.closest('button') ||
target.closest('input') ||
target.closest('.breadcrumb') ||
target.closest('.list-header')
)
return;
@@ -1627,14 +1678,14 @@ function initRubberBandSelection() {
// Sync with multiSelect module
if (multiSelect) {
const info = multiSelect._extractInfo(card);
const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.select(info.id, info.name, info.type, info.parentId);
}
} else {
card.classList.remove('selected');
// Deselect from multiSelect module
if (multiSelect) {
const info = multiSelect._extractInfo(card);
const info = multiSelect._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) multiSelect.deselect(info.id);
}
}
@@ -1651,10 +1702,7 @@ function initRubberBandSelection() {
// Suppress the click event that follows mouseup so the global
// deselect handler doesn't immediately clear the selection.
if (hadSelection) {
__rubberBandJustFinished = true;
requestAnimationFrame(() => {
__rubberBandJustFinished = false;
});
requestAnimationFrame(() => {});
}
});
}
@@ -1709,6 +1757,7 @@ function showConfirmDialog({ title, message, confirmText, cancelText, danger = t
overlay.classList.add('active');
});
/** @param {boolean} result */
const cleanup = (result) => {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200);
+2 -2
View File
@@ -5,7 +5,7 @@
import { isTextViewable } from '../core/formatters.js';
/** @import {FileInfo} from '../core/types.js' */
/** @import {FileItem} from '../core/types.js' */
/** @type {Record<string, string>} */
const ICON_CLASS_MAP = {
@@ -158,7 +158,7 @@ const uiFileTypes = {
// TODO: 'd better to use a canViw() method in inlineViewer
/**
*
* @param {FileInfo} file
* @param {FileItem} file
* @returns {boolean}
*/
isViewableFile(file) {