diff --git a/biome.json b/biome.json index 20ba54c3..b4487259 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,6 @@ { "files": { - "includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json"] + "includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json", "!static/js/vendors/"] }, "formatter": { "enabled": true, @@ -15,7 +15,15 @@ "recommended": true, "correctness": { "noUnusedVariables": "warn", - "noUndeclaredVariables": "error" + "noUndeclaredVariables": "error", + "noUnreachable": "warn", + "noUnsafeFinally": "error" + }, + "nursery": { + "useExplicitType": "error" + }, + "security": { + "noGlobalEval": "error" }, "style": { "noDescendingSpecificity": "off" diff --git a/jsconfig.json b/jsconfig.json index f09567bd..ce15aaf8 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -2,17 +2,22 @@ "compilerOptions": { // Enable type checking on all JS files (equivalent to @ts-check globally) "checkJs": true, + "allowJs": true, "strict": true, - "noImplicitAny": true, + "noEmit": true, + "noImplicitAny": false, "noImplicitReturns": true, "noUnusedLocals": true, "noUnusedParameters": true, "exactOptionalPropertyTypes": true, - "target": "ES2022", "lib": ["ES2022", "DOM"], // Treat all JS files as modules - "moduleDetection": "force" + "moduleDetection": "force", + "strictNullChecks": false, // too much pedantic... + "moduleResolution": "bundler", + "skipLibCheck": true, + "target": "ESNext" }, "include": ["static/js/**/*.js"], - "exclude": [] + "exclude": ["static/js/vendors/**", "static/js/vendors/**/*.mjs"] } diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index a9650ee4..41ff4771 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -9,23 +9,9 @@ import { app } from './state.js'; import { ui } from './ui.js'; import { uiNotifications } from './uiNotifications.js'; -let isLoadingFiles = false; +/** @import {FileInfo, FolderInfo} from '../core/types.js' */ -// TODO move to features/files/fileOperations.js ? -/** - * @typedef {Object} FolderInfo - * @property {string} category - * @property {number} created_at - timestamp - * @property {string} icon_class - * @property {string} icon_special_class - * @property {string} id the uniq id of the folder - * @property {boolean} is_root - * @property {number} modified_at - * @property {string} name - * @property {string} owner_id - * @property {string|null} parent_id the folder parent (null if is_root) - * @property {string} path the full path - */ +let isLoadingFiles = false; /** * getFolder information @@ -99,7 +85,7 @@ async function rebuildBreadCrumb() { uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights'); app.breadcrumbPath = []; id = app.userHomeFolderId; - app.currentPath = id; + if (id) app.currentPath = id; } } @@ -114,6 +100,7 @@ async function rebuildBreadCrumb() { * @param {Object} options * @param {boolean} [options.insertHistory] add browser history (default true) * @param {boolean} [options.forceRefresh] force refresh of content + * */ async function loadFiles(options = { insertHistory: true }) { try { @@ -185,7 +172,7 @@ async function loadFiles(options = { insertHistory: true }) { if (forceRefresh) { url += `&force_refresh=true`; - requestOptions.headers['X-Force-Refresh'] = 'true'; + if (requestOptions.headers) requestOptions.headers['X-Force-Refresh'] = 'true'; console.log('Forcing complete refresh ignoring cache'); } @@ -215,7 +202,10 @@ async function loadFiles(options = { insertHistory: true }) { multiSelect.init(); // this will wire buttons & select-all-checkbox } + /** @type {FolderInfo[]} */ const folderList = Array.isArray(listing.folders) ? listing.folders : []; + + /** @type {FileInfo[]} */ 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 fa07b9d8..10621899 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -31,7 +31,7 @@ import { ui } from './ui.js'; import { setupUserMenu } from './userMenu.js'; // Upload dropdown listener state (prevents accumulated listeners) -/** @type { function | null } */ +/** @type {((e: MouseEvent) => void) | null} */ let uploadDropdownDocumentClickHandler = null; /** @type { AbortController | null } */ @@ -178,7 +178,7 @@ function setupActionsBarDelegation() { actionsBarDelegationBound = true; elements.actionsBar.addEventListener('click', async (e) => { - const btn = e.target.closest('button'); + const btn = /** @type {HTMLElement} */ (e.target)?.closest('button'); if (!btn) return; switch (btn.id) { @@ -321,7 +321,7 @@ function switchSectionTo(section) { // no change ... return; - if ((!section) in SECTIONS_MAPPER) { + if (!(section in SECTIONS_MAPPER)) { console.warn(`context view ${section} unkonwn fallback to files section`); section = 'files'; } @@ -420,7 +420,7 @@ function initApp() { function cacheElements() { elements.uploadBtn = document.getElementById('upload-btn'); elements.dropzone = document.getElementById('dropzone'); - elements.fileInput = document.getElementById('file-input'); + elements.fileInput = /** @type {HTMLInputElement} */ (document.getElementById('file-input')); elements.filesList = document.getElementById('files-list'); elements.newFolderBtn = document.getElementById('new-folder-btn'); elements.gridViewBtn = document.getElementById('grid-view-btn'); @@ -472,7 +472,7 @@ function setupUploadDropdown() { document.removeEventListener('click', uploadDropdownDocumentClickHandler); } uploadDropdownDocumentClickHandler = (e) => { - if (e.target.closest('#upload-dropdown')) return; + if (/** @type {HTMLElement} */ (e.target)?.closest('#upload-dropdown')) return; document.querySelectorAll('.upload-dropdown-menu').forEach((m) => { m.classList.add('hidden'); }); @@ -511,11 +511,11 @@ function setupEventListeners() { }); // Search input — Enter key - elements.searchInput.addEventListener('keydown', (e) => { + elements.searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { // Cancel any pending debounce if (searchDebounceTimer) clearTimeout(searchDebounceTimer); - const query = elements.searchInput.value.trim(); + const query = elements.searchInput?.value.trim(); // In shared section, filter locally if (app.currentSection === 'shared' && sharedView) { @@ -529,16 +529,17 @@ function setupEventListeners() { // If search is empty and we're in search mode, return to normal view app.isSearchMode = false; app.currentPath = ''; - ui.updateBreadcrumb(''); + ui.updateBreadcrumb(); loadFiles(); } } }); // Search input — Live search (debounced, after 3+ chars) - elements.searchInput.addEventListener('input', () => { + elements.searchInput?.addEventListener('input', () => { if (searchDebounceTimer) clearTimeout(searchDebounceTimer); - const query = elements.searchInput.value.trim(); + const query = elements.searchInput?.value.trim(); + if (!query) return; if (query.length >= SEARCH_MIN_CHARS) { searchDebounceTimer = setTimeout(() => { @@ -549,16 +550,16 @@ function setupEventListeners() { searchDebounceTimer = setTimeout(() => { app.isSearchMode = false; app.currentPath = ''; - ui.updateBreadcrumb(''); + ui.updateBreadcrumb(); loadFiles(); }, SEARCH_DEBOUNCE_MS); } }); // Search button - document.getElementById('search-button').addEventListener('click', () => { + document.getElementById('search-button')?.addEventListener('click', () => { if (searchDebounceTimer) clearTimeout(searchDebounceTimer); - const query = elements.searchInput.value.trim(); + const query = elements.searchInput?.value.trim(); if (query) { performSearch(query); } @@ -572,10 +573,13 @@ function setupEventListeners() { } // File input - elements.fileInput.addEventListener('change', (e) => { - if (e.target.files.length > 0) { - fileOps.uploadFiles(e.target.files); - e.target.value = ''; // reset so same file can be re-uploaded + elements.fileInput?.addEventListener('change', (e) => { + const target = /** @type {HTMLInputElement} */ (e.target); + if (!target) return; + if (!target.files) return; + if (target.files.length > 0) { + fileOps.uploadFiles(target.files); + target.value = ''; // reset so same file can be re-uploaded } }); @@ -583,18 +587,21 @@ function setupEventListeners() { const folderInput = document.getElementById('folder-input'); if (folderInput) { folderInput.addEventListener('change', (e) => { - if (e.target.files.length > 0) { - fileOps.uploadFolderFiles(e.target.files); - e.target.value = ''; + const target = /** @type {HTMLInputElement} */ (e.target); + if (!target) return; + if (!target.files) return; + if (target.files.length > 0) { + fileOps.uploadFolderFiles(target.files); + target.value = ''; } }); } // Sidebar navigation - elements.navItems.forEach((item) => { + elements.navItems?.forEach((item) => { item.addEventListener('click', () => { // Remove active class from all nav items - elements.navItems.forEach((navItem) => { + elements.navItems?.forEach((navItem) => { navItem.classList.remove('active'); }); @@ -602,7 +609,7 @@ function setupEventListeners() { item.classList.add('active'); let _updateHistory = true; - const itemI18nKey = item.querySelector('span').getAttribute('data-i18n'); + const itemI18nKey = item.querySelector('span')?.getAttribute('data-i18n'); switch (itemI18nKey) { case 'nav.shared': @@ -661,12 +668,13 @@ function setupEventListeners() { // Global events to close context menus and deselect cards document.addEventListener('click', (e) => { const folderMenu = document.getElementById('folder-context-menu'); - if (folderMenu && !folderMenu.classList.contains('hidden') && !folderMenu.contains(e.target)) { + const target = /** @type {HTMLElement} */ (e.target); + if (folderMenu && !folderMenu.classList.contains('hidden') && !folderMenu.contains(target)) { ui.closeContextMenu(); } const fileMenu = document.getElementById('file-context-menu'); - if (fileMenu && !fileMenu.classList.contains('hidden') && !fileMenu.contains(e.target)) { + if (fileMenu && !fileMenu.classList.contains('hidden') && !fileMenu.contains(target)) { ui.closeFileContextMenu(); } }); @@ -714,8 +722,8 @@ function updateStorageUsageDisplay(userData) { const quotaFormatted = formatQuotaSize(quotaBytes); // Update the storage display elements - const storageFill = document.querySelector('.storage-fill'); - const storageInfo = document.querySelector('.storage-info'); + const storageFill = /** @type {HTMLDivElement} */ (document.querySelector('.storage-fill')); + const storageInfo = /** @type {HTMLDivElement} */ (document.querySelector('.storage-info')); if (storageFill) { storageFill.style.width = `${usagePercentage}%`; diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index 3e2c61b5..3bea519a 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -27,14 +27,14 @@ function syncViewContainers() { const isGrid = app.currentView === 'grid'; if (isGrid) { - filesList.classList.remove('files-list-view'); - filesList.classList.add('files-grid-view'); + filesList?.classList.remove('files-list-view'); + filesList?.classList.add('files-grid-view'); gridViewBtn?.classList.add('active'); listViewBtn?.classList.remove('active'); } else { - filesList.classList.add('files-list-view'); - filesList.classList.remove('files-grid-view'); + filesList?.classList.add('files-list-view'); + filesList?.classList.remove('files-grid-view'); gridViewBtn?.classList.remove('active'); listViewBtn?.classList.add('active'); @@ -47,7 +47,7 @@ function syncViewContainers() { */ function toggleFileContainer(show) { const filesList = document.getElementById('files-list'); - filesList.classList.toggle('hidden', !show); + filesList?.classList.toggle('hidden', !show); } /** @@ -61,19 +61,19 @@ function initSidebarToggle() { if (!sidebarToggle || !sidebar || !sidebarOverlay) return; function openSidebar() { - sidebar.classList.add('open'); - sidebarOverlay.classList.add('active'); + sidebar?.classList.add('open'); + sidebarOverlay?.classList.add('active'); document.body.style.overflow = 'hidden'; } function closeSidebar() { - sidebar.classList.remove('open'); - sidebarOverlay.classList.remove('active'); + sidebar?.classList.remove('open'); + sidebarOverlay?.classList.remove('active'); document.body.style.overflow = ''; } function toggleSidebar() { - if (sidebar.classList.contains('open')) { + if (sidebar?.classList.contains('open')) { closeSidebar(); } else { openSidebar(); @@ -118,6 +118,7 @@ function getSectionFromNavItem(navItem) { } // Mapping section name to associated switch functions +/** @type {Record} */ export const SECTIONS_MAPPER = { files: switchToFilesSection, shared: switchToSharedSection, @@ -144,7 +145,7 @@ function setCurrentSection(section) { app.currentSection = section; // Update nav item active classes by finding matching item from DOM - appElements.navItems.forEach((item) => { + appElements.navItems?.forEach((item) => { const itemSection = getSectionFromNavItem(item); item.classList.toggle('active', itemSection === section); }); @@ -152,8 +153,10 @@ function setCurrentSection(section) { // Update page title const titleKey = `nav.${section}`; // TODO check why no more used: const defaultTitle = section.charAt(0).toUpperCase() + section.slice(1); - appElements.pageTitle.textContent = i18n.t(titleKey); - appElements.pageTitle.setAttribute('data-i18n', titleKey); + if (appElements.pageTitle) { + appElements.pageTitle.textContent = i18n.t(titleKey); + appElements.pageTitle.setAttribute('data-i18n', titleKey); + } // Hide sharedView when switching to any other section if (section !== 'shared' && sharedView) { diff --git a/static/js/app/searchView.js b/static/js/app/searchView.js index 4c7ef06b..1ae99d57 100644 --- a/static/js/app/searchView.js +++ b/static/js/app/searchView.js @@ -16,7 +16,7 @@ async function performSearch(query, sortBy) { try { app.isSearchMode = true; - ui.updateBreadcrumb(`Search: "${query}"`); + ui.updateBreadcrumb(); ui.showError(`

Searching for "${query}"...

`); @@ -48,9 +48,10 @@ async function performSearch(query, sortBy) { } document.addEventListener('search-resort', (e) => { - const searchInput = document.querySelector('.search-container input'); + 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(), e.detail.sort_by); + performSearch(searchInput.value.trim(), event.detail.sort_by); } }); diff --git a/static/js/app/state.js b/static/js/app/state.js index 3911e264..06bc3da6 100644 --- a/static/js/app/state.js +++ b/static/js/app/state.js @@ -3,15 +3,27 @@ * Centralized mutable state for app and cached DOM references. */ +/** @import {FolderInfo} from '../core/types.js' */ + export const app = { currentView: 'grid', + + /** @type {string | null} */ currentPath: '', currentFolder: null, + + /** @type {FolderInfo | null} */ currentFolderInfo: null, + + /** @type {Object | null} */ contextMenuTargetFolder: null, + + /** @type {Object | null} */ contextMenuTargetFile: null, selectedTargetFolderId: '', moveDialogMode: 'file', + + /** @type {String | null} */ currentSection: null, // will be defined on first call isSearchMode: false, shareDialogItem: null, @@ -19,8 +31,36 @@ export const app = { notificationShareUrl: null, userHomeFolderId: null, userHomeFolderName: null, + /** @type {Object[]} */ breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy + + /** @type {String | null} */ viewFile: null // current file in inline view }; -export const appElements = {}; +export const appElements = { + /** @type {HTMLElement | null} */ + uploadBtn: null, + /** @type {HTMLElement | null} */ + dropzone: null, + /** @type {HTMLInputElement | null} */ + fileInput: null, + /** @type {HTMLElement | null} */ + filesList: null, + /** @type {HTMLElement | null} */ + newFolderBtn: null, + /** @type {HTMLElement | null} */ + gridViewBtn: null, + /** @type {HTMLElement | null} */ + listViewBtn: null, + /** @type {HTMLElement | null} */ + breadcrumb: null, + /** @type {HTMLElement | null} */ + pageTitle: null, + /** @type {HTMLElement | null} */ + actionsBar: null, + /** @type {NodeListOf | null} */ + navItems: null, + /** @type {HTMLInputElement | null} */ + searchInput: null +}; diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 0bdd375d..109cd2ff 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -24,8 +24,6 @@ import { app } from './state.js'; import { uiFileTypes } from './uiFileTypes.js'; import { uiNotifications } from './uiNotifications.js'; -let __rubberBandJustFinished = false; - // UI Module const ui = { /** @type {HTMLDListElement | null} */ @@ -235,22 +233,22 @@ const ui = { document.body.appendChild(shareDialog); // Add event listeners for share dialog - document.getElementById('share-close-btn').addEventListener('click', () => { + document.getElementById('share-close-btn')?.addEventListener('click', () => { contextMenus.closeShareDialog(); }); - document.getElementById('share-confirm-btn').addEventListener('click', async () => { + document.getElementById('share-confirm-btn')?.addEventListener('click', async () => { await contextMenus.createSharedLink(); }); - document.getElementById('copy-share-btn').addEventListener('click', async () => { - const shareUrl = document.getElementById('generated-share-url').value; - await fileSharing.copyLinkToClipboard(shareUrl); + document.getElementById('copy-share-btn')?.addEventListener('click', async () => { + const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value; + if (shareUrl) await fileSharing.copyLinkToClipboard(shareUrl); }); - document.getElementById('notify-share-btn').addEventListener('click', () => { - const shareUrl = document.getElementById('generated-share-url').value; - contextMenus.showEmailNotificationDialog(shareUrl); + document.getElementById('notify-share-btn')?.addEventListener('click', () => { + const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value; + if (shareUrl) contextMenus.showEmailNotificationDialog(shareUrl); }); // FIXME make generic function (close all dialog / etc) @@ -301,11 +299,11 @@ const ui = { document.body.appendChild(notificationDialog); // Add event listeners for notification dialog - document.getElementById('notification-cancel-btn').addEventListener('click', () => { + document.getElementById('notification-cancel-btn')?.addEventListener('click', () => { contextMenus.closeNotificationDialog(); }); - document.getElementById('notification-send-btn').addEventListener('click', () => { + document.getElementById('notification-send-btn')?.addEventListener('click', () => { contextMenus.sendShareNotification(); }); } @@ -332,7 +330,7 @@ const ui = { `; document.body.appendChild(playlistDialog); - document.getElementById('playlist-cancel-btn').addEventListener('click', () => { + document.getElementById('playlist-cancel-btn')?.addEventListener('click', () => { if (contextMenus) contextMenus.closePlaylistDialog(); }); } @@ -373,9 +371,9 @@ const ui = { entry.file( (file) => { out.push({ file, relativePath: `${prefix}${file.name}` }); - resolve(); + resolve(undefined); }, - () => resolve() + () => resolve(undefined) ); }); return; @@ -407,20 +405,26 @@ const ui = { }; // Dropzone events - dropzone.addEventListener('dragover', (e) => { + dropzone?.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('active'); }); - dropzone.addEventListener('dragleave', () => { + dropzone?.addEventListener('dragleave', () => { dropzone.classList.remove('active'); }); - dropzone.addEventListener('drop', async (e) => { + // remove previous hack e._oxiHandled + // WeakSet will automatically garbage collect entry + const handledDropEvents = new WeakSet(); + + dropzone?.addEventListener('drop', async (e) => { e.preventDefault(); e.stopPropagation(); // Prevent bubbling to document's drop handler (avoids double upload) - e._oxiHandled = true; // Mark as handled for document-level fallback + handledDropEvents.add(e); // Mark as handled for document-level fallback dropzone.classList.remove('active'); + if (!e.dataTransfer) return; + if (e.dataTransfer.files.length > 0) { // First try directory-aware extraction (Finder folder drag & drop) const droppedEntries = await collectDroppedEntries(e.dataTransfer); @@ -453,6 +457,7 @@ const ui = { // Document-wide drag and drop document.addEventListener('dragover', (e) => { e.preventDefault(); + if (!e.dataTransfer) return; if (e.dataTransfer.types.includes('Files')) { dropzone?.classList.remove('hidden'); dropzone?.classList.add('active'); @@ -461,9 +466,9 @@ const ui = { document.addEventListener('dragleave', (e) => { if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { - dropzone.classList.remove('active'); + dropzone?.classList.remove('active'); setTimeout(() => { - if (!dropzone.classList.contains('active')) { + if (!dropzone?.classList.contains('active')) { dropzone?.classList.add('hidden'); } }, 100); @@ -472,11 +477,12 @@ const ui = { document.addEventListener('drop', async (e) => { e.preventDefault(); - dropzone.classList.remove('active'); + dropzone?.classList.remove('active'); // Skip if already handled by the dropzone handler (defensive against bubble leaks) - if (e._oxiHandled) return; + if (handledDropEvents.has(e)) return; + if (!e.dataTransfer) return; if (e.dataTransfer.files.length > 0) { // First try directory-aware extraction (Finder folder drag & drop) const droppedEntries = await collectDroppedEntries(e.dataTransfer); @@ -539,7 +545,9 @@ const ui = { */ updateBreadcrumb() { const breadcrumb = document.querySelector('.breadcrumb'); - breadcrumb.innerHTML = ''; + if (breadcrumb) { + breadcrumb.innerHTML = ''; + } const path = app.breadcrumbPath; // [{id, name}, ...] // -- Home icon (always present, clickable to go to root) -- @@ -558,7 +566,7 @@ const ui = { loadFiles(); }); } - breadcrumb.appendChild(homeIcon); + breadcrumb?.appendChild(homeIcon); // -- Root/Home folder name (if available) is always the first element of the breadcrumb -- // TODO clarify the difference between homeIcon & this first element @@ -579,7 +587,7 @@ const ui = { const separator = document.createElement('span'); separator.className = 'breadcrumb-separator'; separator.textContent = '>'; - breadcrumb.appendChild(separator); + breadcrumb?.appendChild(separator); // Segment item const item = document.createElement('span'); @@ -600,7 +608,7 @@ const ui = { // can drag files on this folder // dragover – only folders are valid drop targets item.addEventListener('dragover', (e) => { - const card = e.target.closest('span'); + const card = /** @type {HTMLElement} */ (e.target).closest('span'); if (!card?.dataset.folderId) return; e.preventDefault(); card.classList.add('drop-target'); @@ -609,14 +617,14 @@ const ui = { // dragleave item.addEventListener('dragleave', (e) => { console.log('dragleave ', e); - const card = e.target.closest('span'); + const card = /** @type {HTMLElement} */ (e.target).closest('span'); if (!card?.dataset.folderId) return; card.classList.remove('drop-target'); }); // drop – only folders accept drops item.addEventListener('drop', async (e) => { - const card = e.target.closest('span'); + const card = /** @type {HTMLElement} */ (e.target).closest('span'); if (!card) return; const targetFolderId = card.dataset.folderId; if (!targetFolderId) return; @@ -625,13 +633,15 @@ const ui = { card.classList.remove('drop-target'); const action = e.dataTransfer?.dropEffect; - await this._dropToFolder(action, targetFolderId, e.dataTransfer); + if (action) { + await this._dropToFolder(action, targetFolderId, e.dataTransfer); + } }); } else { // Last segment: current location, not clickable item.classList.add('breadcrumb-current'); } - breadcrumb.appendChild(item); + breadcrumb?.appendChild(item); }); }, @@ -915,7 +925,7 @@ const ui = { parent_id: card.dataset.parentId || '' }; } else { - const fileData = info.data || self._items.get(info.id); + const fileData = info.data || this._items.get(info.id); app.contextMenuTargetFile = { id: info.id, name: card.dataset.fileName, @@ -927,27 +937,27 @@ const ui = { // ── click (open / navigate; select only via checkbox) ── filesList.addEventListener('click', (e) => { - const card = e.target.closest('.file-item'); + const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); if (!card) return; - if (e.target.closest('.file-actions')) { + if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) { e.stopPropagation(); e.preventDefault(); const info = itemInfo(card); if (!info) return; setContextTarget(card, info); const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu'; - showContextMenuAtElement(e.target.closest('.file-actions'), menuId); + showContextMenuAtElement(/** @type {HTMLElement} */ (e.target).closest('.file-actions'), menuId); return; } - if (e.target.closest('.checkbox-cell')) { + if (/** @type {HTMLElement} */ (e.target).closest('.checkbox-cell')) { toggleCardSelection(card, e); return; } // Favorite star – handled by direct onclick on the button - if (e.target.closest('.favorite-star')) return; + if (/** @type {HTMLElement} */ (e.target).closest('.favorite-star')) return; // Single-click opens/navigates (selection is only via checkbox) const info = itemInfo(card); @@ -984,7 +994,7 @@ const ui = { // ── shared events ────────────────────── filesList.addEventListener('contextmenu', (e) => { - const card = e.target.closest('.file-item'); + const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); if (!card) return; e.preventDefault(); const info = itemInfo(card); @@ -1001,14 +1011,16 @@ const ui = { if (contextMenus && typeof contextMenus.syncAddToPlaylistOption === 'function') { contextMenus.syncAddToPlaylistOption(); } - menu.style.left = `${e.pageX}px`; - menu.style.top = `${e.pageY}px`; - menu?.classList.remove('hidden'); + if (menu) { + menu.style.left = `${e.pageX}px`; + menu.style.top = `${e.pageY}px`; + menu.classList.remove('hidden'); + } }); // dragstart filesList.addEventListener('dragstart', (e) => { - const card = e.target.closest('.file-item'); + const card = /** @type {HTMLElement} */ (e.target).closest('.file-item'); if (!card) { e.preventDefault(); return; @@ -1020,6 +1032,8 @@ const ui = { return; } + if (!e.dataTransfer) return; + e.dataTransfer.setData('text/plain', info.id); if (info.type === 'folder') { e.dataTransfer.setData('application/oxicloud-folder', 'true'); @@ -1076,7 +1090,7 @@ const ui = { // if more than maxElements display the fading if (selectedCardFromList.length > maxElements) { - lastItemDiv.classList.add('fading'); + lastItemDiv?.classList.add('fading'); } this.dragPreview.appendChild(this.draggedItems); @@ -1093,7 +1107,7 @@ const ui = { // dragover – only folders are valid drop targets filesList.addEventListener('dragover', (e) => { - const card = e.target.closest('.file-item'); + const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card || card.dataset.fileId) return; if (!card.dataset.folderId) return; e.preventDefault(); @@ -1102,14 +1116,14 @@ const ui = { // dragleave filesList.addEventListener('dragleave', (e) => { - const card = e.target.closest('.file-item'); + const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card || card.dataset.fileId) return; card.classList.remove('drop-target'); }); // drop – only folders accept drops filesList.addEventListener('drop', async (e) => { - const card = e.target.closest('.file-item'); + const card = /** @type {HTMLElement} */ (/** @type {HTMLElement} */ (e.target).closest('.file-item')); if (!card || card.dataset.fileId) return; const targetFolderId = card.dataset.folderId; if (!targetFolderId) return; @@ -1117,6 +1131,7 @@ const ui = { e.preventDefault(); card.classList.remove('drop-target'); + if (!e.dataTransfer) return; const action = e.dataTransfer.dropEffect; await this._dropToFolder(action, targetFolderId, e.dataTransfer); }); @@ -1203,7 +1218,7 @@ const ui = { const targetPath = isFavorite ? filledPath : outlinePath; if (svg && targetPath) { const p = svg.querySelector('path'); - if (p) p.setAttribute('d', targetPath[1]); + if (p) p.setAttribute('d', String(targetPath[1])); svg.setAttribute('viewBox', `0 0 ${targetPath[0]} 512`); } @@ -1379,6 +1394,8 @@ const ui = { /** * Render an array of folders into both grid and list views * using DocumentFragment for minimal reflows. + * + * @param {FolderInfo[]} folders */ renderFolders(folders) { if (!this._delegationReady) this.initDelegation(); @@ -1503,6 +1520,8 @@ 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. @@ -1635,7 +1654,7 @@ if (document.readyState === 'loading') { * @param {boolean} [options.danger=false] - Use danger styling (red) * @returns {Promise} true if confirmed, false if cancelled */ -function showConfirmDialog({ title, message, confirmText, cancelText, danger = true } = {}) { +function showConfirmDialog({ title, message, confirmText, cancelText, danger = true }) { const ct = confirmText || i18n.t('actions.delete'); const cc = cancelText || i18n.t('actions.cancel'); const t = title || i18n.t('dialogs.confirm_title'); @@ -1674,8 +1693,8 @@ function showConfirmDialog({ title, message, confirmText, cancelText, danger = t resolve(result); }; - overlay.querySelector('.confirm-dialog-cancel').addEventListener('click', () => cleanup(false)); - overlay.querySelector('.confirm-dialog-ok').addEventListener('click', () => cleanup(true)); + overlay.querySelector('.confirm-dialog-cancel')?.addEventListener('click', () => cleanup(false)); + overlay.querySelector('.confirm-dialog-ok')?.addEventListener('click', () => cleanup(true)); overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); diff --git a/static/js/app/uiFileTypes.js b/static/js/app/uiFileTypes.js index d547df46..497dc462 100644 --- a/static/js/app/uiFileTypes.js +++ b/static/js/app/uiFileTypes.js @@ -5,8 +5,162 @@ import { isTextViewable } from '../core/formatters.js'; +/** @import {FileInfo} from '../core/types.js' */ + +/** @type {Record} */ +const ICON_CLASS_MAP = { + pdf: 'fas fa-file-pdf', + doc: 'fas fa-file-word', + docx: 'fas fa-file-word', + txt: 'fas fa-file-alt', + rtf: 'fas fa-file-alt', + odt: 'fas fa-file-alt', + xls: 'fas fa-file-excel', + xlsx: 'fas fa-file-excel', + csv: 'fas fa-file-excel', + ods: 'fas fa-file-excel', + ppt: 'fas fa-file-powerpoint', + pptx: 'fas fa-file-powerpoint', + odp: 'fas fa-file-powerpoint', + jpg: 'fas fa-file-image', + jpeg: 'fas fa-file-image', + png: 'fas fa-file-image', + gif: 'fas fa-file-image', + svg: 'fas fa-file-image', + webp: 'fas fa-file-image', + bmp: 'fas fa-file-image', + ico: 'fas fa-file-image', + mp4: 'fas fa-file-video', + avi: 'fas fa-file-video', + mov: 'fas fa-file-video', + mkv: 'fas fa-file-video', + webm: 'fas fa-file-video', + flv: 'fas fa-file-video', + mp3: 'fas fa-file-audio', + wav: 'fas fa-file-audio', + ogg: 'fas fa-file-audio', + flac: 'fas fa-file-audio', + aac: 'fas fa-file-audio', + m4a: 'fas fa-file-audio', + zip: 'fas fa-file-archive', + rar: 'fas fa-file-archive', + '7z': 'fas fa-file-archive', + tar: 'fas fa-file-archive', + gz: 'fas fa-file-archive', + js: 'fas fa-file-code', + ts: 'fas fa-file-code', + py: 'fas fa-file-code', + rs: 'fas fa-file-code', + java: 'fas fa-file-code', + html: 'fas fa-file-code', + css: 'fas fa-file-code', + json: 'fas fa-file-code', + xml: 'fas fa-file-code', + sh: 'fas fa-terminal', + bash: 'fas fa-terminal', + bat: 'fas fa-terminal', + md: 'fas fa-file-alt' +}; + +/** @type {Record} */ +const ICON_SPECIAL_CLASS_MAP = { + pdf: 'pdf-icon', + doc: 'doc-icon', + docx: 'doc-icon', + odt: 'doc-icon', + rtf: 'doc-icon', + xls: 'spreadsheet-icon', + xlsx: 'spreadsheet-icon', + ods: 'spreadsheet-icon', + csv: 'spreadsheet-icon', + ppt: 'presentation-icon', + pptx: 'presentation-icon', + odp: 'presentation-icon', + key: 'presentation-icon', + jpg: 'image-icon', + jpeg: 'image-icon', + png: 'image-icon', + gif: 'image-icon', + svg: 'image-icon', + webp: 'image-icon', + bmp: 'image-icon', + ico: 'image-icon', + heic: 'image-icon', + heif: 'image-icon', + avif: 'image-icon', + tiff: 'image-icon', + mp4: 'video-icon', + avi: 'video-icon', + mkv: 'video-icon', + mov: 'video-icon', + wmv: 'video-icon', + flv: 'video-icon', + webm: 'video-icon', + m4v: 'video-icon', + mp3: 'audio-icon', + wav: 'audio-icon', + ogg: 'audio-icon', + flac: 'audio-icon', + aac: 'audio-icon', + wma: 'audio-icon', + m4a: 'audio-icon', + opus: 'audio-icon', + zip: 'archive-icon', + rar: 'archive-icon', + '7z': 'archive-icon', + tar: 'archive-icon', + gz: 'archive-icon', + bz2: 'archive-icon', + xz: 'archive-icon', + exe: 'installer-icon', + msi: 'installer-icon', + dmg: 'installer-icon', + deb: 'installer-icon', + rpm: 'installer-icon', + appimage: 'installer-icon', + py: 'code-icon py-icon', + rs: 'code-icon rust-icon', + go: 'code-icon go-icon', + js: 'code-icon js-icon', + jsx: 'code-icon js-icon', + mjs: 'code-icon js-icon', + ts: 'code-icon ts-icon', + tsx: 'code-icon ts-icon', + java: 'code-icon java-icon', + c: 'code-icon c-icon', + cpp: 'code-icon c-icon', + cs: 'code-icon cs-icon', + rb: 'code-icon ruby-icon', + php: 'code-icon php-icon', + swift: 'code-icon swift-icon', + html: 'code-icon html-icon', + htm: 'code-icon html-icon', + css: 'code-icon css-icon', + scss: 'code-icon css-icon', + json: 'code-icon json-icon', + xml: 'code-icon html-icon', + yaml: 'code-icon config-icon', + yml: 'code-icon config-icon', + toml: 'code-icon config-icon', + ini: 'code-icon config-icon', + sql: 'code-icon sql-icon', + vue: 'code-icon js-icon', + svelte: 'code-icon js-icon', + sh: 'script-icon', + bash: 'script-icon', + zsh: 'script-icon', + bat: 'script-icon', + md: 'code-icon md-icon', + txt: 'doc-icon' +}; + const uiFileTypes = { // TODO: 'd better to use a canViw() method in inlineViewer + /** + * + * @param {FileInfo} file + * @returns {boolean} + */ isViewableFile(file) { if (!file?.mime_type) return false; if (file.mime_type.startsWith('image/')) return true; @@ -16,159 +170,28 @@ const uiFileTypes = { return isTextViewable(file.mime_type); }, + /** + * + * @param {string} fileName + * @returns {string} + */ getIconClass(fileName) { if (!fileName) return 'fas fa-file'; const ext = (fileName.split('.').pop() || '').toLowerCase(); - const map = { - pdf: 'fas fa-file-pdf', - doc: 'fas fa-file-word', - docx: 'fas fa-file-word', - txt: 'fas fa-file-alt', - rtf: 'fas fa-file-alt', - odt: 'fas fa-file-alt', - xls: 'fas fa-file-excel', - xlsx: 'fas fa-file-excel', - csv: 'fas fa-file-excel', - ods: 'fas fa-file-excel', - ppt: 'fas fa-file-powerpoint', - pptx: 'fas fa-file-powerpoint', - odp: 'fas fa-file-powerpoint', - jpg: 'fas fa-file-image', - jpeg: 'fas fa-file-image', - png: 'fas fa-file-image', - gif: 'fas fa-file-image', - svg: 'fas fa-file-image', - webp: 'fas fa-file-image', - bmp: 'fas fa-file-image', - ico: 'fas fa-file-image', - mp4: 'fas fa-file-video', - avi: 'fas fa-file-video', - mov: 'fas fa-file-video', - mkv: 'fas fa-file-video', - webm: 'fas fa-file-video', - flv: 'fas fa-file-video', - mp3: 'fas fa-file-audio', - wav: 'fas fa-file-audio', - ogg: 'fas fa-file-audio', - flac: 'fas fa-file-audio', - aac: 'fas fa-file-audio', - m4a: 'fas fa-file-audio', - zip: 'fas fa-file-archive', - rar: 'fas fa-file-archive', - '7z': 'fas fa-file-archive', - tar: 'fas fa-file-archive', - gz: 'fas fa-file-archive', - js: 'fas fa-file-code', - ts: 'fas fa-file-code', - py: 'fas fa-file-code', - rs: 'fas fa-file-code', - java: 'fas fa-file-code', - html: 'fas fa-file-code', - css: 'fas fa-file-code', - json: 'fas fa-file-code', - xml: 'fas fa-file-code', - sh: 'fas fa-terminal', - bash: 'fas fa-terminal', - bat: 'fas fa-terminal', - md: 'fas fa-file-alt' - }; - return map[ext] || 'fas fa-file'; + + return ICON_CLASS_MAP[ext] || 'fas fa-file'; }, + /** + * + * @param {string} fileName + * @returns + */ getIconSpecialClass(fileName) { if (!fileName) return ''; const ext = (fileName.split('.').pop() || '').toLowerCase(); - const map = { - pdf: 'pdf-icon', - doc: 'doc-icon', - docx: 'doc-icon', - odt: 'doc-icon', - rtf: 'doc-icon', - xls: 'spreadsheet-icon', - xlsx: 'spreadsheet-icon', - ods: 'spreadsheet-icon', - csv: 'spreadsheet-icon', - ppt: 'presentation-icon', - pptx: 'presentation-icon', - odp: 'presentation-icon', - key: 'presentation-icon', - jpg: 'image-icon', - jpeg: 'image-icon', - png: 'image-icon', - gif: 'image-icon', - svg: 'image-icon', - webp: 'image-icon', - bmp: 'image-icon', - ico: 'image-icon', - heic: 'image-icon', - heif: 'image-icon', - avif: 'image-icon', - tiff: 'image-icon', - mp4: 'video-icon', - avi: 'video-icon', - mkv: 'video-icon', - mov: 'video-icon', - wmv: 'video-icon', - flv: 'video-icon', - webm: 'video-icon', - m4v: 'video-icon', - mp3: 'audio-icon', - wav: 'audio-icon', - ogg: 'audio-icon', - flac: 'audio-icon', - aac: 'audio-icon', - wma: 'audio-icon', - m4a: 'audio-icon', - opus: 'audio-icon', - zip: 'archive-icon', - rar: 'archive-icon', - '7z': 'archive-icon', - tar: 'archive-icon', - gz: 'archive-icon', - bz2: 'archive-icon', - xz: 'archive-icon', - exe: 'installer-icon', - msi: 'installer-icon', - dmg: 'installer-icon', - deb: 'installer-icon', - rpm: 'installer-icon', - appimage: 'installer-icon', - py: 'code-icon py-icon', - rs: 'code-icon rust-icon', - go: 'code-icon go-icon', - js: 'code-icon js-icon', - jsx: 'code-icon js-icon', - mjs: 'code-icon js-icon', - ts: 'code-icon ts-icon', - tsx: 'code-icon ts-icon', - java: 'code-icon java-icon', - c: 'code-icon c-icon', - cpp: 'code-icon c-icon', - cs: 'code-icon cs-icon', - rb: 'code-icon ruby-icon', - php: 'code-icon php-icon', - swift: 'code-icon swift-icon', - html: 'code-icon html-icon', - htm: 'code-icon html-icon', - css: 'code-icon css-icon', - scss: 'code-icon css-icon', - json: 'code-icon json-icon', - xml: 'code-icon html-icon', - yaml: 'code-icon config-icon', - yml: 'code-icon config-icon', - toml: 'code-icon config-icon', - ini: 'code-icon config-icon', - sql: 'code-icon sql-icon', - vue: 'code-icon js-icon', - svelte: 'code-icon js-icon', - sh: 'script-icon', - bash: 'script-icon', - zsh: 'script-icon', - bat: 'script-icon', - md: 'code-icon md-icon', - txt: 'doc-icon' - }; - return map[ext] || ''; + + return ICON_SPECIAL_CLASS_MAP[ext] || ''; } }; diff --git a/static/js/app/uiNotifications.js b/static/js/app/uiNotifications.js index 1e675c8f..75a5d3c1 100644 --- a/static/js/app/uiNotifications.js +++ b/static/js/app/uiNotifications.js @@ -6,6 +6,12 @@ import { notifications } from '../core/notifications.js'; const uiNotifications = { + /** + * + * @param {string} title + * @param {string} message + * @returns + */ show(title, message) { const normalizedTitle = String(title || '').toLowerCase(); let icon = 'fa-info-circle'; diff --git a/static/js/app/userMenu.js b/static/js/app/userMenu.js index 81e106d4..ad4c8457 100644 --- a/static/js/app/userMenu.js +++ b/static/js/app/userMenu.js @@ -49,7 +49,7 @@ function setupUserMenu() { }); document.addEventListener('click', (e) => { - if (wrapper.classList.contains('open') && !wrapper.contains(e.target)) { + if (wrapper.classList.contains('open') && !wrapper.contains(/** @type {Node|null} */ (e.target))) { wrapper.classList.remove('open'); } }); @@ -137,7 +137,7 @@ function setupUserMenu() { const aboutOverlay = document.getElementById('about-modal-overlay'); if (aboutCloseBtn) { aboutCloseBtn.addEventListener('click', () => { - aboutOverlay.classList.add('hidden'); + aboutOverlay?.classList.add('hidden'); }); } if (aboutOverlay) { @@ -242,7 +242,7 @@ function showUserProfileModal() { `; // Set dynamic bar width and color via JS property (CSP-safe) - const barFill = overlay.querySelector('#about-bar-fill'); + const barFill = /** @type {HTMLDivElement} */ (overlay.querySelector('#about-bar-fill')); if (barFill) { barFill.style.width = `${percentage}%`; barFill.style.background = barColor; @@ -251,7 +251,7 @@ function showUserProfileModal() { document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('show')); - overlay.querySelector('#profile-modal-close').addEventListener('click', () => { + overlay.querySelector('#profile-modal-close')?.addEventListener('click', () => { overlay.classList.remove('show'); setTimeout(() => overlay.remove(), 200); }); diff --git a/static/js/core/formatters.js b/static/js/core/formatters.js index 72671db6..e24d99d6 100644 --- a/static/js/core/formatters.js +++ b/static/js/core/formatters.js @@ -3,11 +3,21 @@ * Centralized global helpers for date/size/text formatting and XSS-safe escaping. */ +/** + * + * @param {string} str + * @returns {string} + */ function escapeHtml(str) { if (typeof str !== 'string') return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } +/** + * + * @param {number} bytes + * @returns {string} + */ function formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; @@ -19,11 +29,21 @@ function formatFileSize(bytes) { } /// Formats a byte count for quota display. When bytes is 0, returns "∞" (unlimited). +/** + * + * @param {number} bytes + * @returns {string} + */ function formatQuotaSize(bytes) { if (bytes === 0) return '∞'; return formatFileSize(bytes); } +/** + * + * @param {Date | number| null} value + * @returns {string} + */ function formatDateTime(value) { if (!value) return ''; let dateValue; @@ -38,6 +58,11 @@ function formatDateTime(value) { return `${dateValue.toLocaleDateString()} ${dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; } +/** + * + * @param {Date | number| null} value + * @returns {string} + */ function formatDateShort(value) { if (!value) return 'N/A'; const dateValue = typeof value === 'number' ? new Date(value * 1000) : new Date(value); @@ -49,20 +74,27 @@ function formatDateShort(value) { }); } +const TEXT_TYPES = [ + 'application/json', + 'application/xml', + 'application/javascript', + 'application/x-sh', + 'application/x-yaml', + 'application/toml', + 'application/x-toml', + 'application/sql' +]; +// FIXME: move is to another file +/** + * + * @param {string} mimeType + * @returns {boolean} + */ function isTextViewable(mimeType) { if (!mimeType) return false; if (mimeType.startsWith('text/')) return true; - const textTypes = [ - 'application/json', - 'application/xml', - 'application/javascript', - 'application/x-sh', - 'application/x-yaml', - 'application/toml', - 'application/x-toml', - 'application/sql' - ]; - return textTypes.includes(mimeType); + + return TEXT_TYPES.includes(mimeType); } export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isTextViewable }; diff --git a/static/js/core/i18n.js b/static/js/core/i18n.js index c46ad23a..55294e5a 100644 --- a/static/js/core/i18n.js +++ b/static/js/core/i18n.js @@ -18,6 +18,7 @@ if (!supportedLocales.includes(currentLocale)) { } // Cache for translations +/** @type {Record} */ const translations = {}; /** diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 9437a3b5..ecdc6470 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -450,7 +450,7 @@ function replaceIconsInElement(container) { if (!container) container = document.body; const icons = container.querySelectorAll('i[class*="fa-"]'); for (let i = 0; i < icons.length; i++) { - const el = icons[i]; + const el = /** @type {HTMLElement} */ (icons[i]); const classes = el.className.split(/\s+/); // Find the icon name (fa-xxx) diff --git a/static/js/core/languageSelector.js b/static/js/core/languageSelector.js index 6468f545..bfae22c0 100644 --- a/static/js/core/languageSelector.js +++ b/static/js/core/languageSelector.js @@ -96,7 +96,7 @@ function createLanguageSelector(containerId = 'language-selector') { option.className = `language-option${lang.code === currentLocale ? ' active' : ''}`; option.setAttribute('role', 'option'); option.setAttribute('data-lang', lang.code); - option.setAttribute('aria-selected', lang.code === currentLocale); + option.setAttribute('aria-selected', String(lang.code === currentLocale)); option.innerHTML = ` ${lang.flag} ${lang.name} @@ -134,7 +134,7 @@ function createLanguageSelector(containerId = 'language-selector') { // Close dropdown when clicking outside document.addEventListener('click', (e) => { - if (!container.contains(e.target)) { + if (!container.contains(/** @type {Node | null } */ (e.target))) { closeDropdown(container); } }); diff --git a/static/js/core/modal.js b/static/js/core/modal.js index 016f623d..c29e17cf 100644 --- a/static/js/core/modal.js +++ b/static/js/core/modal.js @@ -53,9 +53,9 @@ const Modal = { this.closeBtn = document.getElementById('modal-close-btn'); // Event listeners - this.cancelBtn.addEventListener('click', () => this.close(false)); - this.closeBtn.addEventListener('click', () => this.close(false)); - this.confirmBtn.addEventListener('click', () => this.confirm()); + this.cancelBtn?.addEventListener('click', () => this.close(false)); + this.closeBtn?.addEventListener('click', () => this.close(false)); + this.confirmBtn?.addEventListener('click', () => this.confirm()); // Close on overlay click this.overlay.addEventListener('click', (e) => { @@ -65,7 +65,7 @@ const Modal = { }); // Handle Enter and Escape keys - this.input.addEventListener('keydown', (e) => { + this.input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.confirm(); diff --git a/static/js/core/notifications.js b/static/js/core/notifications.js index 4fe484b8..5d46d767 100644 --- a/static/js/core/notifications.js +++ b/static/js/core/notifications.js @@ -34,7 +34,7 @@ const notifications = (() => { bellBtn.addEventListener('click', (e) => { e.stopPropagation(); - const open = wrapper.classList.toggle('open'); + const open = wrapper?.classList.toggle('open'); bellBtn.classList.toggle('active', open); // Close user-menu if it's open @@ -46,7 +46,7 @@ const notifications = (() => { // Close on outside click document.addEventListener('click', (e) => { - if (!wrapper.contains(e.target)) { + if (!wrapper?.contains(/** @type {Node | null} */ (e.target))) { close(); } }); @@ -63,8 +63,8 @@ const notifications = (() => { function close() { const bellBtn = $('notif-bell-btn'); const wrapper = $('notif-wrapper'); - wrapper.classList.remove('open'); - bellBtn.classList.remove('active'); + wrapper?.classList.remove('open'); + bellBtn?.classList.remove('active'); } /* ── badge helpers ──────────────────────────────────────── */ @@ -82,7 +82,7 @@ const notifications = (() => { if (!badge) return; if (_badgeCount > 0) { badge.classList.remove('hidden'); - badge.textContent = _badgeCount > 99 ? '99+' : _badgeCount; + badge.textContent = _badgeCount > 99 ? '99+' : String(_badgeCount); } else { badge.classList.add('hidden'); } diff --git a/static/js/core/types.js b/static/js/core/types.js new file mode 100644 index 00000000..4d51f773 --- /dev/null +++ b/static/js/core/types.js @@ -0,0 +1,55 @@ +/** + * @typedef {Object} FolderInfo + * @property {string} category + * @property {number} created_at - timestamp + * @property {string} icon_class + * @property {string} icon_special_class + * @property {string} id the uniq id of the folder + * @property {boolean} is_root + * @property {number} modified_at + * @property {string} name + * @property {string} owner_id + * @property {string|null} parent_id the folder parent (null if is_root) + * @property {string} path the full path + */ + +/** + * @typedef {Object} FileInfo + * @property {string} category + * @property {number} created_at - timestamp + * @property {string} icon_class + * @property {string} icon_special_class + * @property {string} id the uniq id of the folder + * @property {string} mime_type + * @property {number} modified_at - timestamp + * @property {string} name + * @property {string} owner_id + * @property {string} folder_id the folder parent + * @property {string} path the full path + * @property {number} size + * @property {string} size_formatted + * @property {number} sort_date + */ + +/** + * @typedef {Object} SharePermissions + * @property {boolean} read + * @property {boolean} reshare + * @property {boolean} write + */ + +/** + * @typedef {Object} Share + * @property {number} access_count + * @property {number} created_at - timestamp + * @property {String} created_by + * @property {number} expires_at - timestamp + * @property {boolean} has_password + * @property {string} id + * @property {string} item_id + * @property {string} item_name + * @property {string} item_type + * @property {SharePermissions} permissions + * @property {string | null} token + * @property {string} url + */ diff --git a/static/js/features/auth/auth.js b/static/js/features/auth/auth.js index 122ace65..4ccae22f 100644 --- a/static/js/features/auth/auth.js +++ b/static/js/features/auth/auth.js @@ -405,7 +405,7 @@ function initLanguageSelector() { const pickerName = document.getElementById('lang-picker-name'); const searchInput = document.getElementById('lang-picker-search-input'); - if (!languagePanel || !picker) return; + if (!languagePanel || !picker || !pickerFlag || !pickerName || !pickerList) return; // --- Auto-detect browser language --- const detected = detectBrowserLanguage(); diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index 184c91b4..7296b3c8 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -271,7 +271,7 @@ const fileOps = { /** * Upload files to the server with real-time progress indication - * @param {FileList} files - Files to upload + * @param {FileList | File[]} files - Files to upload */ async uploadFiles(files) { const originalFiles = Array.from(files || []); diff --git a/static/js/features/files/search.js b/static/js/features/files/search.js index e47ad8da..1f678c79 100644 --- a/static/js/features/files/search.js +++ b/static/js/features/files/search.js @@ -146,14 +146,14 @@ const search = { if (previousSearchHeader) { previousSearchHeader.replaceWith(searchHeader); } else { - pageStickyHeader.appendChild(searchHeader); + pageStickyHeader?.appendChild(searchHeader); } // Sort dropdown — re-searches with new sort order (server-side) - const sortSelect = document.getElementById('search-sort-select'); + const sortSelect = /** @type {HTMLSelectElement} */ (document.getElementById('search-sort-select')); if (sortSelect) { sortSelect.addEventListener('change', () => { - const searchInput = document.querySelector('.search-container input'); + const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input')); if (searchInput?.value.trim()) { const event = new CustomEvent('search-resort', { detail: { sort_by: sortSelect.value } @@ -167,11 +167,12 @@ const search = { const clearSearchBtn = document.getElementById('clear-search-btn'); if (clearSearchBtn) { clearSearchBtn.addEventListener('click', () => { - document.querySelector('.search-container input').value = ''; + const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input')); + searchInput.value = ''; app.currentPath = ''; app.isSearchMode = false; document.querySelector('.search-results-header')?.remove(); - ui.updateBreadcrumb(''); + ui.updateBreadcrumb(); loadFiles(); }); } diff --git a/static/js/features/files/wopiEditor.js b/static/js/features/files/wopiEditor.js index 7c3f487d..8401fe47 100644 --- a/static/js/features/files/wopiEditor.js +++ b/static/js/features/files/wopiEditor.js @@ -6,6 +6,7 @@ */ import { loadFiles } from '../../app/filesView.js'; +import { ui } from '../../app/ui.js'; class WopiEditor { constructor() { @@ -46,9 +47,7 @@ class WopiEditor { window.open(hostUrl, '_blank'); } catch (error) { console.error('Failed to open WOPI editor in tab:', error); - if (window.showNotification) { - window.showNotification('Could not open the document editor.', 'error'); - } + ui.showNotification('Could not open the document editor.', 'error'); } } diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 79ab0e31..7fb19e63 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -171,7 +171,7 @@ const favorites = { // FIXME: this case is not easy to understand, should apply better implementation multiSelect.init(); - ui.updateBreadcrumb(''); + ui.updateBreadcrumb(); if (this._cache.size === 0) { ui.showError(` diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index eee64332..9b405779 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -214,7 +214,7 @@ const photosView = { if (grid?.classList.contains('photos-grid')) { grid.insertAdjacentHTML('beforeend', tilesHtml); const countSpan = existingHeader.querySelector('.photos-day-count'); - if (countSpan) countSpan.textContent = grid.children.length; + if (countSpan) countSpan.textContent = String(grid.children.length); } } else { // New group — insert header + grid before sentinel @@ -269,11 +269,14 @@ const photosView = { /** @param {number} [startIndex=0] When > 0, only process video tiles * for items[startIndex..] — avoids re-scanning the entire DOM. */ _setupVideoThumbnails(startIndex = 0) { - const tiles = this._container.querySelectorAll('.photo-tile[data-mime^="video/"]'); + const tiles = /** @type {NodeListOf */ (this._container?.querySelectorAll('.photo-tile[data-mime^="video/"]')); const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null; + if (!tiles) return; + for (const tile of tiles) { const fileId = tile.dataset.id; + if (!fileId) continue; if (newIds && !newIds.has(fileId)) continue; if (this._videoThumbCache.has(fileId)) continue; @@ -313,6 +316,8 @@ const photosView = { /** Extract a single frame from a video and display it as the tile * thumbnail, then upload the JPEG to the server for caching. */ _generateVideoThumbnail(tile, img) { + // TODO: use thumbnail.js s common lib + const fileId = tile.dataset.id; const video = document.createElement('video'); video.crossOrigin = 'anonymous'; @@ -341,7 +346,7 @@ const photosView = { canvas.width = Math.round(video.videoWidth * scale); canvas.height = Math.round(video.videoHeight * scale); const ctx = canvas.getContext('2d'); - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + ctx?.drawImage(video, 0, 0, canvas.width, canvas.height); // JPEG: explicit quality control, universally supported, // and server stores as-is when dimensions fit (zero re-encode). @@ -362,7 +367,7 @@ const photosView = { // Upload to server for permanent caching const token = localStorage.getItem('token') || sessionStorage.getItem('token'); - const headers = { 'Content-Type': blob.type, ...getCsrfHeaders() }; + const headers = /** @type {Record} */ ({ 'Content-Type': blob.type, ...getCsrfHeaders() }); if (token) headers.Authorization = `Bearer ${token}`; fetch(`/api/files/${fileId}/thumbnail/preview`, { @@ -426,6 +431,7 @@ const photosView = { /** Render empty state */ _renderEmpty() { + if (!this._container) return; this._container.innerHTML = `
@@ -526,44 +532,53 @@ const photosView = { `; - bar.querySelector('#photos-sel-clear').onclick = () => { - this.selected.clear(); - this._container.querySelectorAll('.photo-tile.selected').forEach((t) => { - t.classList.remove('selected'); - }); - this._hideSelectionBar(); - }; + const bar_clear = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-clear')); + if (bar_clear) { + bar_clear.onclick = () => { + this.selected.clear(); + this._container.querySelectorAll('.photo-tile.selected').forEach((t) => { + t.classList.remove('selected'); + }); + this._hideSelectionBar(); + }; + } - bar.querySelector('#photos-sel-delete').onclick = async () => { - if (!confirm('Delete selected items?')) return; - for (const fid of this.selected) { - try { - await fetch(`/api/files/${fid}`, { - method: 'DELETE', - credentials: 'include', - headers: this._headers() - }); - } catch (err) { - console.error('Delete failed:', fid, err); + const bar_delete = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-delete')); + if (bar_delete) { + bar_delete.onclick = async () => { + if (!confirm('Delete selected items?')) return; + for (const fid of this.selected) { + try { + await fetch(`/api/files/${fid}`, { + method: 'DELETE', + credentials: 'include', + headers: this._headers() + }); + } catch (err) { + console.error('Delete failed:', fid, err); + } } - } - this.items = this.items.filter((f) => !this.selected.has(f.id)); - this.selected.clear(); - this._hideSelectionBar(); - this._renderedCount = 0; - this._renderFull(); - }; + this.items = this.items.filter((f) => !this.selected.has(f.id)); + this.selected.clear(); + this._hideSelectionBar(); + this._renderedCount = 0; + this._renderFull(); + }; + } - bar.querySelector('#photos-sel-download').onclick = async () => { - for (const fid of this.selected) { - const a = document.createElement('a'); - a.href = `/api/files/${fid}`; - a.download = ''; - document.body.appendChild(a); - a.click(); - a.remove(); - } - }; + const bar_download = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-download')); + if (bar_download) { + bar_download.onclick = async () => { + for (const fid of this.selected) { + const a = document.createElement('a'); + a.href = `/api/files/${fid}`; + a.download = ''; + document.body.appendChild(a); + a.click(); + a.remove(); + } + }; + } bar.style.display = 'flex'; }, diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 792bed58..833b33a7 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -109,7 +109,7 @@ const recent = { multiSelect.clear(); multiSelect.init(); // this will wire buttons & select-all-checkbox } - ui.updateBreadcrumb(''); + ui.updateBreadcrumb(); if (recentItems.length === 0) { ui.showError(` diff --git a/static/js/features/sharing/fileSharing.js b/static/js/features/sharing/fileSharing.js index 54f72a45..150b8734 100644 --- a/static/js/features/sharing/fileSharing.js +++ b/static/js/features/sharing/fileSharing.js @@ -165,7 +165,7 @@ const fileSharing = { /** * Format expiration date for display (Unix timestamp in seconds or ISO string) - * @param {number|string} value + * @param {number|Date} value * @returns {string} */ formatExpirationDate(value) { @@ -177,7 +177,7 @@ const fileSharing = { * Send a notification about a shared resource (stub — no backend endpoint yet) * @param {string} shareUrl * @param {string} recipientEmail - * @param {string} message + * @param {string} _message * @returns {Promise} */ async sendShareNotification(shareUrl, recipientEmail, _message = '') { diff --git a/static/js/views/device-verify/device-verify.js b/static/js/views/device-verify/device-verify.js index 8983f1ac..3e0eb523 100644 --- a/static/js/views/device-verify/device-verify.js +++ b/static/js/views/device-verify/device-verify.js @@ -3,7 +3,7 @@ import { getCsrfHeaders } from '../../core/csrf.js'; (() => { var API_BASE = window.location.origin; - var codeInput = document.getElementById('user-code'); + var codeInput = /** @type {HTMLInputElement} */ (document.getElementById('user-code')); var deviceInfo = document.getElementById('device-info'); var actionButtons = document.getElementById('action-buttons'); var errorText = document.getElementById('error-text'); diff --git a/static/js/views/nextcloud/error.js b/static/js/views/nextcloud/error.js index 85fe5322..405600f1 100644 --- a/static/js/views/nextcloud/error.js +++ b/static/js/views/nextcloud/error.js @@ -6,6 +6,10 @@ var errorTitle = document.getElementById('error-title'); var errorMessage = document.getElementById('error-message'); var errorAction = document.getElementById('error-action'); +if (!errorTitle || !errorMessage || !errorAction) { + throw new Error('missing html elements'); +} + switch (errorType) { case 'invalid-credentials': errorTitle.textContent = 'Login Failed'; diff --git a/static/js/views/shared/sharedView.js b/static/js/views/shared/sharedView.js index d252d750..bd47ca88 100644 --- a/static/js/views/shared/sharedView.js +++ b/static/js/views/shared/sharedView.js @@ -10,10 +10,14 @@ import { formatDateShort } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import { fileSharing } from '../../features/sharing/fileSharing.js'; +/** @import {Share} from '../../core/types.js' */ + const TTL = 5 * 60 * 1000; // 5 min const sharedView = { // State + + /** @type {Array} */ items: [], _expires: 0, @@ -21,6 +25,7 @@ const sharedView = { /** @type {Map} key = "file:" | "folder:" */ _knownItemsId: new Map(), + /** @type {Array} */ filteredItems: [], currentItem: null, @@ -67,7 +72,7 @@ const sharedView = { * * @param {boolean} force ignore cache */ - async loadItems(force) { + async loadItems(force = false) { if (this._expires > Date.now() && !force) return; try {