style(ui): request that all types defined

- check in more restrictive mode = request types
- define main types in static/js/core/types.js
This commit is contained in:
Edouard Vanbelle
2026-04-30 02:02:27 +02:00
parent 97ea87efc2
commit 3050556dc0
29 changed files with 560 additions and 345 deletions
+10 -2
View File
@@ -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"
+9 -4
View File
@@ -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"]
}
+8 -18
View File
@@ -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) {
+35 -27
View File
@@ -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}%`;
+16 -13
View File
@@ -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<String, Function>} */
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) {
+4 -3
View File
@@ -16,7 +16,7 @@ async function performSearch(query, sortBy) {
try {
app.isSearchMode = true;
ui.updateBreadcrumb(`Search: "${query}"`);
ui.updateBreadcrumb();
ui.showError(`<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>`);
@@ -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);
}
});
+41 -1
View File
@@ -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<HTMLElement> | null} */
navItems: null,
/** @type {HTMLInputElement | null} */
searchInput: null
};
+69 -50
View File
@@ -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<boolean>} 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);
});
+168 -145
View File
@@ -5,8 +5,162 @@
import { isTextViewable } from '../core/formatters.js';
/** @import {FileInfo} from '../core/types.js' */
/** @type {Record<string, string>} */
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<string, string>} */
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] || '';
}
};
+6
View File
@@ -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';
+4 -4
View File
@@ -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);
});
+43 -11
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
/**
*
* @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 };
+1
View File
@@ -18,6 +18,7 @@ if (!supportedLocales.includes(currentLocale)) {
}
// Cache for translations
/** @type {Record<string, Object>} */
const translations = {};
/**
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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 = `
<span class="lang-flag">${lang.flag}</span>
<span class="lang-name">${lang.name}</span>
@@ -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);
}
});
+4 -4
View File
@@ -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();
+5 -5
View File
@@ -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');
}
+55
View File
@@ -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
*/
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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 || []);
+6 -5
View File
@@ -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();
});
}
+2 -3
View File
@@ -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');
}
}
+1 -1
View File
@@ -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(`
+54 -39
View File
@@ -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<HTMLDivElement> */ (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<String, String>} */ ({ '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 = `
<div class="photos-empty">
<i class="fas fa-images"></i>
@@ -526,44 +532,53 @@ const photosView = {
<button id="photos-sel-clear" title="Clear"><i class="fas fa-times"></i></button>
`;
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';
},
+1 -1
View File
@@ -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(`
+2 -2
View File
@@ -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<boolean>}
*/
async sendShareNotification(shareUrl, recipientEmail, _message = '') {
@@ -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');
+4
View File
@@ -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';
+6 -1
View File
@@ -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<Share>} */
items: [],
_expires: 0,
@@ -21,6 +25,7 @@ const sharedView = {
/** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */
_knownItemsId: new Map(),
/** @type {Array<Share>} */
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 {