diff --git a/jsconfig.json b/jsconfig.json index ddf88703..0820c537 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -5,10 +5,13 @@ "allowJs": true, "strict": true, "noEmit": true, - "noImplicitAny": false, + "noImplicitAny": true, + "noImplicitThis": true, "noImplicitReturns": true, "noUnusedLocals": true, "noUnusedParameters": true, + "strictFunctionTypes": true, + "lib": ["ES2022", "DOM"], // Treat all JS files as modules "moduleDetection": "force", @@ -17,6 +20,6 @@ "skipLibCheck": true, "target": "ESNext" }, - "include": ["static/js/**/*.js"], - "exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs"] + "include": ["static/js/**/*.js" ], + "exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs", "static/js/vendors/**/*.js" ] } diff --git a/justfile b/justfile index b630a1d2..4296eee6 100644 --- a/justfile +++ b/justfile @@ -59,6 +59,7 @@ front-fmt: front-lint: biome lint static/ + tsc -p jsconfig.json # check CSS rules front-rules: diff --git a/static/js/app/authSession.js b/static/js/app/authSession.js index a1ef161e..68101541 100644 --- a/static/js/app/authSession.js +++ b/static/js/app/authSession.js @@ -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} + */ 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 diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 9bc94db5..c8f01d7c 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -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} + * @returns {Promise} */ 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) { diff --git a/static/js/app/main.js b/static/js/app/main.js index 626980e9..8d9ce11e 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -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} */ 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 diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 3bea519a..4cfba60f 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -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 diff --git a/static/js/app/searchView.js b/static/js/app/searchView.js index 1ae99d57..25a56d93 100644 --- a/static/js/app/searchView.js +++ b/static/js/app/searchView.js @@ -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(`

Searching for "${query}"...

`); + /** @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); } }); diff --git a/static/js/app/state.js b/static/js/app/state.js index 06bc3da6..3d030c84 100644 --- a/static/js/app/state.js +++ b/static/js/app/state.js @@ -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 = { diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index f69d8542..8d2517fb 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -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() { `; - 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'; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 52bf89f9..3e472373 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -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} + */ 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} item data keyed by id */ + /** @type {Map} item data keyed by id */ _items: new Map(), - /** @type {Array} last rendered folder dataset */ + /** @type {FolderItem[]} last rendered folder dataset */ _lastFolders: [], - /** @type {Array} 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} */ (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