diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f0a3c3a..e66e91c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: - 'tests/**' frontend-check: - name: Frontend — CSS and JS checks (format, lint, rules) + name: Frontend — CSS and JS checks (format, lint, css-rules, types) needs: changes if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest @@ -61,16 +61,20 @@ jobs: node-version: 25 # because we are not using package.json - - name: Install Stylelint and plugins + - name: Install Stylelint, TypeScript and plugins run: | npm install --global \ stylelint@17 \ postcss@8 \ - stylelint-value-no-unknown-custom-properties@6 + stylelint-value-no-unknown-custom-properties@6 \ + typescript - name: Run Stylelint run: npx stylelint "static/css/**/*.{css,scss}" + - name: Run TypeScript check + run: tsc -p jsconfig.json --noEmit + rust-fmt: name: Rustfmt needs: changes diff --git a/CLAUDE.md b/CLAUDE.md index c267348a..a5a38694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,7 @@ Never duplicate logic across handlers or services. If the same behaviour is need - Naming: `camelCase` for variables/functions, `PascalCase` for classes - No `var` — use `const`/`let` only - **JSDoc required** on all public functions — `jsconfig.json` enables `checkJs` globally (equivalent to `@ts-check` on every file) +- Always us static/js/core/types.js to mapp OxCcloud API structure - Type parameters, return types, and complex types via `@typedef`: ```js @@ -162,6 +163,17 @@ Never duplicate logic across JS modules. If the same behaviour is needed in more - One CSS file per logical component in `/static/css/` - [data-theme="dark"] is permitted only in /static/css/themes/dark.css +## Frontend Pre-commit checks + +Always run these before committing, in this order: + +```bash +biome check --fix # Auto-format +biome lint --fix # Lint (must pass) +stylelint static/css/ # Css rules +tsc -p jsconfig.json --noEmit # Ensure JS is always typed +``` + # What Claude must NOT do - Edit `Cargo.lock` directly - Use npm dependencies not listed in this file diff --git a/build.rs b/build.rs index b1685a21..24712d81 100644 --- a/build.rs +++ b/build.rs @@ -173,6 +173,7 @@ fn resolve_css_imports(entry: &Path, css_dir: &Path) -> String { if let Some(rel) = extract_import_path(t) { let resolved = css_dir.join(rel.trim_start_matches("./")); if resolved.exists() { + println!("cargo:warning=CSS importing: {}", resolved.display()); out.push_str(&fs::read_to_string(&resolved).unwrap_or_default()); out.push('\n'); } else { @@ -238,6 +239,7 @@ fn minify_tree_css(dir: &Path) { if fname.starts_with("app.") || fname == "main.css" { continue; } + println!("cargo:warning=CSS importing: {}", p.display()); if let Ok(src) = fs::read_to_string(&p) { let _ = fs::write(&p, css_minify_safe(&src)); } @@ -284,12 +286,23 @@ fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String collect_module_deps(&path, &mut order, &mut seen); } + println!( + "cargo:warning=bundle: {} files in dependency order:", + order.len() + ); let mut bundle = String::with_capacity(2 * 1024 * 1024); bundle.push_str("(function(){\n\"use strict\";\n"); - for file in &order { + let mut declared_namespaces = std::collections::HashSet::new(); + for (i, file) in order.iter().enumerate() { + println!( + "cargo:warning=bundle [{:>3}/{}] {}", + i + 1, + order.len(), + file.display() + ); match fs::read_to_string(file) { Ok(src) => { - bundle.push_str(&strip_esm_syntax(&src)); + bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces)); bundle.push('\n'); } Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()), @@ -326,7 +339,11 @@ fn collect_module_deps( // Skip vendor bundles: they may use top-level await or other ESM // patterns that are incompatible with IIFE wrapping. They must be // loaded via dynamic import() at runtime instead. - if !target.components().any(|c| c.as_os_str() == "vendors") { + // Skip also workers path + if !target + .components() + .any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers") + { collect_module_deps(&target, order, seen); } } @@ -381,20 +398,82 @@ fn extract_from_clause(s: &str) -> Option { Some(rest[1..end].to_string()) } +/// Extract all names that a JS module source exports. +/// +/// Handles: +/// - `export { X, Y };` and `export { X as Z };` +/// - `export function f`, `export async function f`, `export class C` +/// - `export const X`, `export let X`, `export var X` +/// +/// Does NOT follow `export { X } from '...'` re-exports. +fn extract_exported_names(source: &str) -> Vec { + let mut names = Vec::new(); + + for line in source.lines() { + let t = line.trim(); + + // export { X, Y } — skip re-exports from other modules + if (t.starts_with("export {") || t.starts_with("export{")) && !t.contains(" from ") { + if let (Some(start), Some(end)) = (t.find('{'), t.find('}')) { + for binding in t[start + 1..end].split(',') { + let b = binding.trim(); + let exported = if let Some(pos) = b.find(" as ") { + b[pos + 4..].trim() + } else { + b + }; + if !exported.is_empty() { + names.push(exported.to_string()); + } + } + } + continue; + } + + const DECL_PREFIXES: &[&str] = &[ + "export async function ", + "export function ", + "export class ", + "export const ", + "export let ", + "export var ", + ]; + for prefix in DECL_PREFIXES { + if let Some(rest) = t.strip_prefix(prefix) { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$') + .collect(); + if !name.is_empty() { + names.push(name); + } + break; + } + } + } + + names +} + /// Strip ES-module syntax from a single file so it can be inlined into an IIFE. /// -/// | Input | Output | -/// |------------------------------------------|------------------------------| -/// | `import { X } from './y.js';` | *(empty line)* | -/// | `import { X as Y } from './y.js';` | `const Y = X;` | -/// | `export { X, Y };` | *(empty line)* | -/// | `export { X } from './y.js';` | *(empty line)* | -/// | `export const X = …` | `const X = …` | -/// | `export function f() {…}` | `function f() {…}` | -/// | `export async function f() {…}` | `async function f() {…}` | -/// | `export class C {…}` | `class C {…}` | -/// | `export default expr;` | `const _default = expr;` | -fn strip_esm_syntax(source: &str) -> String { +/// | Input | Output | +/// |------------------------------------------|---------------------------------------------| +/// | `import { X } from './y.js';` | *(empty line)* | +/// | `import { X as Y } from './y.js';` | `const Y = X;` | +/// | `import * as ns from './y.js';` | `const ns = { export1, export2, … };` | +/// | `export { X, Y };` | *(empty line)* | +/// | `export { X } from './y.js';` | *(empty line)* | +/// | `export const X = …` | `const X = …` | +/// | `export function f() {…}` | `function f() {…}` | +/// | `export async function f() {…}` | `async function f() {…}` | +/// | `export class C {…}` | `class C {…}` | +/// | `export default expr;` | `const _default = expr;` | +fn strip_esm_syntax( + source: &str, + file: &Path, + declared_namespaces: &mut std::collections::HashSet, +) -> String { let mut out = String::with_capacity(source.len()); // True while we are inside a multi-line import/export-list that has not yet // seen its terminating `;`. @@ -412,6 +491,66 @@ fn strip_esm_syntax(source: &str) -> String { continue; } + // ── import * as ns from './path.js' ─────────────────────────────────── + // Build a synthetic namespace object from the module's exports so that + // `ns.foo()` calls resolve correctly inside the IIFE scope. + // If multiple files import the same namespace name, only the first + // declaration is emitted — subsequent ones become empty lines to avoid + // `SyntaxError: Identifier already declared`. + if t.starts_with("import * as ") { + let stmt = (|| -> Option { + // Extract the namespace identifier + let after_as = t.strip_prefix("import * as ")?; + let name_end = after_as.find(' ')?; + let ns_name = &after_as[..name_end]; + + // Already declared earlier in the bundle — skip re-declaration. + if declared_namespaces.contains(ns_name) { + return Some(String::new()); + } + + // Extract the module path from the `from '…'` clause + let module_path = extract_from_clause(t)?; + if !module_path.starts_with('.') { + return None; // bare specifier — not bundled + } + + // Skip vendor/worker bundles (dynamically loaded at runtime) + let base = file.parent().unwrap_or(Path::new(".")); + let target = base.join(&module_path); + if target + .components() + .any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers") + { + return None; + } + + let module_src = fs::read_to_string(&target).ok()?; + let exports = extract_exported_names(&module_src); + if exports.is_empty() { + return None; + } + + declared_namespaces.insert(ns_name.to_string()); + let indent = &line[..line.len() - line.trim_start().len()]; + Some(format!( + "{}const {} = {{ {} }};", + indent, + ns_name, + exports.join(", ") + )) + })(); + + match stmt { + Some(s) => out.push_str(&s), + None => { + println!("cargo:warning=bundle: could not resolve namespace import: {t}"); + } + } + out.push('\n'); + continue; + } + // ── import … ────────────────────────────────────────────────────────── // Emit `const Y = X;` for any `import { X as Y }` aliases so that code // using the aliased name still resolves inside the IIFE scope. @@ -600,6 +739,7 @@ fn minify_tree_js(dir: &Path) { continue; } if let Ok(src) = fs::read_to_string(&p) { + println!("cargo:warning=minify-js: {}", p.display()); let _ = fs::write(&p, js_minify_safe(&src)); } } 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..10a8528a 100644 --- a/justfile +++ b/justfile @@ -60,6 +60,10 @@ front-fmt: front-lint: biome lint static/ +# test types (JSDOC), using typescript +front-type: + tsc -p jsconfig.json --noEmit + # check CSS rules front-rules: stylelint static/css/ diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index c2169367..d57f805f 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -155,13 +155,7 @@ impl FolderUseCase for FolderService { let folder = self .folder_storage .create_folder(dto.name, dto.parent_id) - .await - .map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!("Failed to create folder: {}", e), - ) - })?; + .await?; // Convert to DTO Ok(FolderDto::from(folder)) diff --git a/src/common/di.rs b/src/common/di.rs index 0c371f58..d0f5a5fe 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -841,8 +841,8 @@ impl AppServiceFactory { tracing::warn!("╔══════════════════════════════════════════════════════════╗"); tracing::warn!("║ SYSTEM NOT INITIALIZED — first admin setup required ║"); tracing::warn!("║ ║"); - tracing::warn!("║ Open the web UI to create the first admin account. ║"); - tracing::warn!("║ The setup page is available until an admin is created. ║"); + tracing::warn!("║ Open the web UI to create the first admin account. ║"); + tracing::warn!("║ The setup page is available until an admin is created. ║"); tracing::warn!("╚══════════════════════════════════════════════════════════╝"); } else { tracing::info!("System already initialized — setup endpoint disabled"); diff --git a/static/css/main.css b/static/css/main.css index 97ee96bc..38c68ac7 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -27,6 +27,7 @@ @import url("./components/search.css"); @import url("./components/icons.css"); @import url("./components/csp-utilities.css"); +@import url("./components/pathTooltip.css"); /* Theme */ @import url("./themes/dark.css"); diff --git a/static/index.html b/static/index.html index ab48c0af..16692523 100644 --- a/static/index.html +++ b/static/index.html @@ -18,7 +18,6 @@ - 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..a878f867 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; @@ -727,15 +744,17 @@ const ui = { const frag = document.createDocumentFragment(); for (const folder of folders) { - frag.appendChild(this._createFolderItem(folder)); + try { + frag.appendChild(this._createFolderItem(folder)); + } catch (e) { + console.warn(`Error building folder item `, folder, `reason: `, e); + } } target.appendChild(frag); }, /** - * - * @param {Object[]} files - * @returns + * @param {FileItem[]} files */ _renderFilesToView(files) { if (!Array.isArray(files) || files.length === 0) return; @@ -744,11 +763,19 @@ const ui = { const frag = document.createDocumentFragment(); for (const file of files) { - frag.appendChild(this._createFileItem(file)); + try { + frag.appendChild(this._createFileItem(file)); + } catch (e) { + console.warn(`Error building file item `, file, `reason: `, e); + } } 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 +823,7 @@ const ui = { await fileOps.moveFile(sourceId, targetFolderId); */ + /** @type {BatchResult} */ let result; switch (action) { case 'copy': @@ -843,6 +871,7 @@ const ui = { this._delegationReady = true; // ── helpers ──────────────────────────────────────────────── + /** @param {HTMLDivElement} card */ const itemInfo = (card) => { if (!card) return null; const fileId = card.dataset.fileId; @@ -864,6 +893,7 @@ const ui = { return null; }; + /** @param {FileItem} file */ const openFile = async (file) => { if (!file) return; if (recent) { @@ -897,6 +927,7 @@ const ui = { } }; + /** @param {HTMLElement} card */ const navigateFolder = (card) => { const folderId = card.dataset.folderId; const folderName = card.dataset.folderName; @@ -912,27 +943,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 +1010,7 @@ const ui = { if (info.type === 'folder') { navigateFolder(card); } else { - openFile(info.data); + openFile(/** @type {FileItem} */ (info.data)); } }); @@ -989,7 +1024,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 +1043,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 +1123,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 +1201,7 @@ const ui = { * Favorite star helper – attaches a direct click handler to a * star