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": { "files": {
"includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json"] "includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json", "!static/js/vendors/"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@@ -15,7 +15,15 @@
"recommended": true, "recommended": true,
"correctness": { "correctness": {
"noUnusedVariables": "warn", "noUnusedVariables": "warn",
"noUndeclaredVariables": "error" "noUndeclaredVariables": "error",
"noUnreachable": "warn",
"noUnsafeFinally": "error"
},
"nursery": {
"useExplicitType": "error"
},
"security": {
"noGlobalEval": "error"
}, },
"style": { "style": {
"noDescendingSpecificity": "off" "noDescendingSpecificity": "off"
+9 -4
View File
@@ -2,17 +2,22 @@
"compilerOptions": { "compilerOptions": {
// Enable type checking on all JS files (equivalent to @ts-check globally) // Enable type checking on all JS files (equivalent to @ts-check globally)
"checkJs": true, "checkJs": true,
"allowJs": true,
"strict": true, "strict": true,
"noImplicitAny": true, "noEmit": true,
"noImplicitAny": false,
"noImplicitReturns": true, "noImplicitReturns": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
"target": "ES2022",
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
// Treat all JS files as modules // 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"], "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 { ui } from './ui.js';
import { uiNotifications } from './uiNotifications.js'; import { uiNotifications } from './uiNotifications.js';
let isLoadingFiles = false; /** @import {FileInfo, FolderInfo} from '../core/types.js' */
// TODO move to features/files/fileOperations.js ? let isLoadingFiles = false;
/**
* @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
*/
/** /**
* getFolder information * 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'); uiNotifications.show('error: folder not found or permission denied', 'the given folder is not available or you do not have sufficient rights');
app.breadcrumbPath = []; app.breadcrumbPath = [];
id = app.userHomeFolderId; id = app.userHomeFolderId;
app.currentPath = id; if (id) app.currentPath = id;
} }
} }
@@ -114,6 +100,7 @@ async function rebuildBreadCrumb() {
* @param {Object} options * @param {Object} options
* @param {boolean} [options.insertHistory] add browser history (default true) * @param {boolean} [options.insertHistory] add browser history (default true)
* @param {boolean} [options.forceRefresh] force refresh of content * @param {boolean} [options.forceRefresh] force refresh of content
*
*/ */
async function loadFiles(options = { insertHistory: true }) { async function loadFiles(options = { insertHistory: true }) {
try { try {
@@ -185,7 +172,7 @@ async function loadFiles(options = { insertHistory: true }) {
if (forceRefresh) { if (forceRefresh) {
url += `&force_refresh=true`; 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'); 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 multiSelect.init(); // this will wire buttons & select-all-checkbox
} }
/** @type {FolderInfo[]} */
const folderList = Array.isArray(listing.folders) ? listing.folders : []; const folderList = Array.isArray(listing.folders) ? listing.folders : [];
/** @type {FileInfo[]} */
const fileList = Array.isArray(listing.files) ? listing.files : []; const fileList = Array.isArray(listing.files) ? listing.files : [];
if (folderList.length === 0 && fileList.length === 0) { 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'; import { setupUserMenu } from './userMenu.js';
// Upload dropdown listener state (prevents accumulated listeners) // Upload dropdown listener state (prevents accumulated listeners)
/** @type { function | null } */ /** @type {((e: MouseEvent) => void) | null} */
let uploadDropdownDocumentClickHandler = null; let uploadDropdownDocumentClickHandler = null;
/** @type { AbortController | null } */ /** @type { AbortController | null } */
@@ -178,7 +178,7 @@ function setupActionsBarDelegation() {
actionsBarDelegationBound = true; actionsBarDelegationBound = true;
elements.actionsBar.addEventListener('click', async (e) => { elements.actionsBar.addEventListener('click', async (e) => {
const btn = e.target.closest('button'); const btn = /** @type {HTMLElement} */ (e.target)?.closest('button');
if (!btn) return; if (!btn) return;
switch (btn.id) { switch (btn.id) {
@@ -321,7 +321,7 @@ function switchSectionTo(section) {
// no change ... // no change ...
return; return;
if ((!section) in SECTIONS_MAPPER) { if (!(section in SECTIONS_MAPPER)) {
console.warn(`context view ${section} unkonwn fallback to files section`); console.warn(`context view ${section} unkonwn fallback to files section`);
section = 'files'; section = 'files';
} }
@@ -420,7 +420,7 @@ function initApp() {
function cacheElements() { function cacheElements() {
elements.uploadBtn = document.getElementById('upload-btn'); elements.uploadBtn = document.getElementById('upload-btn');
elements.dropzone = document.getElementById('dropzone'); 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.filesList = document.getElementById('files-list');
elements.newFolderBtn = document.getElementById('new-folder-btn'); elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn'); elements.gridViewBtn = document.getElementById('grid-view-btn');
@@ -472,7 +472,7 @@ function setupUploadDropdown() {
document.removeEventListener('click', uploadDropdownDocumentClickHandler); document.removeEventListener('click', uploadDropdownDocumentClickHandler);
} }
uploadDropdownDocumentClickHandler = (e) => { 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) => { document.querySelectorAll('.upload-dropdown-menu').forEach((m) => {
m.classList.add('hidden'); m.classList.add('hidden');
}); });
@@ -511,11 +511,11 @@ function setupEventListeners() {
}); });
// Search input — Enter key // Search input — Enter key
elements.searchInput.addEventListener('keydown', (e) => { elements.searchInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
// Cancel any pending debounce // Cancel any pending debounce
if (searchDebounceTimer) clearTimeout(searchDebounceTimer); if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim(); const query = elements.searchInput?.value.trim();
// In shared section, filter locally // In shared section, filter locally
if (app.currentSection === 'shared' && sharedView) { 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 // If search is empty and we're in search mode, return to normal view
app.isSearchMode = false; app.isSearchMode = false;
app.currentPath = ''; app.currentPath = '';
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
loadFiles(); loadFiles();
} }
} }
}); });
// Search input — Live search (debounced, after 3+ chars) // Search input — Live search (debounced, after 3+ chars)
elements.searchInput.addEventListener('input', () => { elements.searchInput?.addEventListener('input', () => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer); 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) { if (query.length >= SEARCH_MIN_CHARS) {
searchDebounceTimer = setTimeout(() => { searchDebounceTimer = setTimeout(() => {
@@ -549,16 +550,16 @@ function setupEventListeners() {
searchDebounceTimer = setTimeout(() => { searchDebounceTimer = setTimeout(() => {
app.isSearchMode = false; app.isSearchMode = false;
app.currentPath = ''; app.currentPath = '';
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
loadFiles(); loadFiles();
}, SEARCH_DEBOUNCE_MS); }, SEARCH_DEBOUNCE_MS);
} }
}); });
// Search button // Search button
document.getElementById('search-button').addEventListener('click', () => { document.getElementById('search-button')?.addEventListener('click', () => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer); if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim(); const query = elements.searchInput?.value.trim();
if (query) { if (query) {
performSearch(query); performSearch(query);
} }
@@ -572,10 +573,13 @@ function setupEventListeners() {
} }
// File input // File input
elements.fileInput.addEventListener('change', (e) => { elements.fileInput?.addEventListener('change', (e) => {
if (e.target.files.length > 0) { const target = /** @type {HTMLInputElement} */ (e.target);
fileOps.uploadFiles(e.target.files); if (!target) return;
e.target.value = ''; // reset so same file can be re-uploaded 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'); const folderInput = document.getElementById('folder-input');
if (folderInput) { if (folderInput) {
folderInput.addEventListener('change', (e) => { folderInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) { const target = /** @type {HTMLInputElement} */ (e.target);
fileOps.uploadFolderFiles(e.target.files); if (!target) return;
e.target.value = ''; if (!target.files) return;
if (target.files.length > 0) {
fileOps.uploadFolderFiles(target.files);
target.value = '';
} }
}); });
} }
// Sidebar navigation // Sidebar navigation
elements.navItems.forEach((item) => { elements.navItems?.forEach((item) => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
// Remove active class from all nav items // Remove active class from all nav items
elements.navItems.forEach((navItem) => { elements.navItems?.forEach((navItem) => {
navItem.classList.remove('active'); navItem.classList.remove('active');
}); });
@@ -602,7 +609,7 @@ function setupEventListeners() {
item.classList.add('active'); item.classList.add('active');
let _updateHistory = true; let _updateHistory = true;
const itemI18nKey = item.querySelector('span').getAttribute('data-i18n'); const itemI18nKey = item.querySelector('span')?.getAttribute('data-i18n');
switch (itemI18nKey) { switch (itemI18nKey) {
case 'nav.shared': case 'nav.shared':
@@ -661,12 +668,13 @@ function setupEventListeners() {
// Global events to close context menus and deselect cards // Global events to close context menus and deselect cards
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const folderMenu = document.getElementById('folder-context-menu'); 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(); ui.closeContextMenu();
} }
const fileMenu = document.getElementById('file-context-menu'); 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(); ui.closeFileContextMenu();
} }
}); });
@@ -714,8 +722,8 @@ function updateStorageUsageDisplay(userData) {
const quotaFormatted = formatQuotaSize(quotaBytes); const quotaFormatted = formatQuotaSize(quotaBytes);
// Update the storage display elements // Update the storage display elements
const storageFill = document.querySelector('.storage-fill'); const storageFill = /** @type {HTMLDivElement} */ (document.querySelector('.storage-fill'));
const storageInfo = document.querySelector('.storage-info'); const storageInfo = /** @type {HTMLDivElement} */ (document.querySelector('.storage-info'));
if (storageFill) { if (storageFill) {
storageFill.style.width = `${usagePercentage}%`; storageFill.style.width = `${usagePercentage}%`;
+14 -11
View File
@@ -27,14 +27,14 @@ function syncViewContainers() {
const isGrid = app.currentView === 'grid'; const isGrid = app.currentView === 'grid';
if (isGrid) { if (isGrid) {
filesList.classList.remove('files-list-view'); filesList?.classList.remove('files-list-view');
filesList.classList.add('files-grid-view'); filesList?.classList.add('files-grid-view');
gridViewBtn?.classList.add('active'); gridViewBtn?.classList.add('active');
listViewBtn?.classList.remove('active'); listViewBtn?.classList.remove('active');
} else { } else {
filesList.classList.add('files-list-view'); filesList?.classList.add('files-list-view');
filesList.classList.remove('files-grid-view'); filesList?.classList.remove('files-grid-view');
gridViewBtn?.classList.remove('active'); gridViewBtn?.classList.remove('active');
listViewBtn?.classList.add('active'); listViewBtn?.classList.add('active');
@@ -47,7 +47,7 @@ function syncViewContainers() {
*/ */
function toggleFileContainer(show) { function toggleFileContainer(show) {
const filesList = document.getElementById('files-list'); 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; if (!sidebarToggle || !sidebar || !sidebarOverlay) return;
function openSidebar() { function openSidebar() {
sidebar.classList.add('open'); sidebar?.classList.add('open');
sidebarOverlay.classList.add('active'); sidebarOverlay?.classList.add('active');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
function closeSidebar() { function closeSidebar() {
sidebar.classList.remove('open'); sidebar?.classList.remove('open');
sidebarOverlay.classList.remove('active'); sidebarOverlay?.classList.remove('active');
document.body.style.overflow = ''; document.body.style.overflow = '';
} }
function toggleSidebar() { function toggleSidebar() {
if (sidebar.classList.contains('open')) { if (sidebar?.classList.contains('open')) {
closeSidebar(); closeSidebar();
} else { } else {
openSidebar(); openSidebar();
@@ -118,6 +118,7 @@ function getSectionFromNavItem(navItem) {
} }
// Mapping section name to associated switch functions // Mapping section name to associated switch functions
/** @type {Record<String, Function>} */
export const SECTIONS_MAPPER = { export const SECTIONS_MAPPER = {
files: switchToFilesSection, files: switchToFilesSection,
shared: switchToSharedSection, shared: switchToSharedSection,
@@ -144,7 +145,7 @@ function setCurrentSection(section) {
app.currentSection = section; app.currentSection = section;
// Update nav item active classes by finding matching item from DOM // Update nav item active classes by finding matching item from DOM
appElements.navItems.forEach((item) => { appElements.navItems?.forEach((item) => {
const itemSection = getSectionFromNavItem(item); const itemSection = getSectionFromNavItem(item);
item.classList.toggle('active', itemSection === section); item.classList.toggle('active', itemSection === section);
}); });
@@ -152,8 +153,10 @@ function setCurrentSection(section) {
// Update page title // Update page title
const titleKey = `nav.${section}`; const titleKey = `nav.${section}`;
// TODO check why no more used: const defaultTitle = section.charAt(0).toUpperCase() + section.slice(1); // TODO check why no more used: const defaultTitle = section.charAt(0).toUpperCase() + section.slice(1);
if (appElements.pageTitle) {
appElements.pageTitle.textContent = i18n.t(titleKey); appElements.pageTitle.textContent = i18n.t(titleKey);
appElements.pageTitle.setAttribute('data-i18n', titleKey); appElements.pageTitle.setAttribute('data-i18n', titleKey);
}
// Hide sharedView when switching to any other section // Hide sharedView when switching to any other section
if (section !== 'shared' && sharedView) { if (section !== 'shared' && sharedView) {
+4 -3
View File
@@ -16,7 +16,7 @@ async function performSearch(query, sortBy) {
try { try {
app.isSearchMode = true; 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>`); 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) => { 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()) { 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. * Centralized mutable state for app and cached DOM references.
*/ */
/** @import {FolderInfo} from '../core/types.js' */
export const app = { export const app = {
currentView: 'grid', currentView: 'grid',
/** @type {string | null} */
currentPath: '', currentPath: '',
currentFolder: null, currentFolder: null,
/** @type {FolderInfo | null} */
currentFolderInfo: null, currentFolderInfo: null,
/** @type {Object | null} */
contextMenuTargetFolder: null, contextMenuTargetFolder: null,
/** @type {Object | null} */
contextMenuTargetFile: null, contextMenuTargetFile: null,
selectedTargetFolderId: '', selectedTargetFolderId: '',
moveDialogMode: 'file', moveDialogMode: 'file',
/** @type {String | null} */
currentSection: null, // will be defined on first call currentSection: null, // will be defined on first call
isSearchMode: false, isSearchMode: false,
shareDialogItem: null, shareDialogItem: null,
@@ -19,8 +31,36 @@ export const app = {
notificationShareUrl: null, notificationShareUrl: null,
userHomeFolderId: null, userHomeFolderId: null,
userHomeFolderName: null, userHomeFolderName: null,
/** @type {Object[]} */
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy 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
}; };
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
};
+65 -46
View File
@@ -24,8 +24,6 @@ import { app } from './state.js';
import { uiFileTypes } from './uiFileTypes.js'; import { uiFileTypes } from './uiFileTypes.js';
import { uiNotifications } from './uiNotifications.js'; import { uiNotifications } from './uiNotifications.js';
let __rubberBandJustFinished = false;
// UI Module // UI Module
const ui = { const ui = {
/** @type {HTMLDListElement | null} */ /** @type {HTMLDListElement | null} */
@@ -235,22 +233,22 @@ const ui = {
document.body.appendChild(shareDialog); document.body.appendChild(shareDialog);
// Add event listeners for share dialog // Add event listeners for share dialog
document.getElementById('share-close-btn').addEventListener('click', () => { document.getElementById('share-close-btn')?.addEventListener('click', () => {
contextMenus.closeShareDialog(); contextMenus.closeShareDialog();
}); });
document.getElementById('share-confirm-btn').addEventListener('click', async () => { document.getElementById('share-confirm-btn')?.addEventListener('click', async () => {
await contextMenus.createSharedLink(); await contextMenus.createSharedLink();
}); });
document.getElementById('copy-share-btn').addEventListener('click', async () => { document.getElementById('copy-share-btn')?.addEventListener('click', async () => {
const shareUrl = document.getElementById('generated-share-url').value; const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
await fileSharing.copyLinkToClipboard(shareUrl); if (shareUrl) await fileSharing.copyLinkToClipboard(shareUrl);
}); });
document.getElementById('notify-share-btn').addEventListener('click', () => { document.getElementById('notify-share-btn')?.addEventListener('click', () => {
const shareUrl = document.getElementById('generated-share-url').value; const shareUrl = /** @type {HTMLInputElement | null} */ (document.getElementById('generated-share-url'))?.value;
contextMenus.showEmailNotificationDialog(shareUrl); if (shareUrl) contextMenus.showEmailNotificationDialog(shareUrl);
}); });
// FIXME make generic function (close all dialog / etc) // FIXME make generic function (close all dialog / etc)
@@ -301,11 +299,11 @@ const ui = {
document.body.appendChild(notificationDialog); document.body.appendChild(notificationDialog);
// Add event listeners for notification dialog // Add event listeners for notification dialog
document.getElementById('notification-cancel-btn').addEventListener('click', () => { document.getElementById('notification-cancel-btn')?.addEventListener('click', () => {
contextMenus.closeNotificationDialog(); contextMenus.closeNotificationDialog();
}); });
document.getElementById('notification-send-btn').addEventListener('click', () => { document.getElementById('notification-send-btn')?.addEventListener('click', () => {
contextMenus.sendShareNotification(); contextMenus.sendShareNotification();
}); });
} }
@@ -332,7 +330,7 @@ const ui = {
`; `;
document.body.appendChild(playlistDialog); document.body.appendChild(playlistDialog);
document.getElementById('playlist-cancel-btn').addEventListener('click', () => { document.getElementById('playlist-cancel-btn')?.addEventListener('click', () => {
if (contextMenus) contextMenus.closePlaylistDialog(); if (contextMenus) contextMenus.closePlaylistDialog();
}); });
} }
@@ -373,9 +371,9 @@ const ui = {
entry.file( entry.file(
(file) => { (file) => {
out.push({ file, relativePath: `${prefix}${file.name}` }); out.push({ file, relativePath: `${prefix}${file.name}` });
resolve(); resolve(undefined);
}, },
() => resolve() () => resolve(undefined)
); );
}); });
return; return;
@@ -407,20 +405,26 @@ const ui = {
}; };
// Dropzone events // Dropzone events
dropzone.addEventListener('dragover', (e) => { dropzone?.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
dropzone.classList.add('active'); dropzone.classList.add('active');
}); });
dropzone.addEventListener('dragleave', () => { dropzone?.addEventListener('dragleave', () => {
dropzone.classList.remove('active'); 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.preventDefault();
e.stopPropagation(); // Prevent bubbling to document's drop handler (avoids double upload) 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'); dropzone.classList.remove('active');
if (!e.dataTransfer) return;
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {
// First try directory-aware extraction (Finder folder drag & drop) // First try directory-aware extraction (Finder folder drag & drop)
const droppedEntries = await collectDroppedEntries(e.dataTransfer); const droppedEntries = await collectDroppedEntries(e.dataTransfer);
@@ -453,6 +457,7 @@ const ui = {
// Document-wide drag and drop // Document-wide drag and drop
document.addEventListener('dragover', (e) => { document.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
if (!e.dataTransfer) return;
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer.types.includes('Files')) {
dropzone?.classList.remove('hidden'); dropzone?.classList.remove('hidden');
dropzone?.classList.add('active'); dropzone?.classList.add('active');
@@ -461,9 +466,9 @@ const ui = {
document.addEventListener('dragleave', (e) => { document.addEventListener('dragleave', (e) => {
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
dropzone.classList.remove('active'); dropzone?.classList.remove('active');
setTimeout(() => { setTimeout(() => {
if (!dropzone.classList.contains('active')) { if (!dropzone?.classList.contains('active')) {
dropzone?.classList.add('hidden'); dropzone?.classList.add('hidden');
} }
}, 100); }, 100);
@@ -472,11 +477,12 @@ const ui = {
document.addEventListener('drop', async (e) => { document.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
dropzone.classList.remove('active'); dropzone?.classList.remove('active');
// Skip if already handled by the dropzone handler (defensive against bubble leaks) // 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) { if (e.dataTransfer.files.length > 0) {
// First try directory-aware extraction (Finder folder drag & drop) // First try directory-aware extraction (Finder folder drag & drop)
const droppedEntries = await collectDroppedEntries(e.dataTransfer); const droppedEntries = await collectDroppedEntries(e.dataTransfer);
@@ -539,7 +545,9 @@ const ui = {
*/ */
updateBreadcrumb() { updateBreadcrumb() {
const breadcrumb = document.querySelector('.breadcrumb'); const breadcrumb = document.querySelector('.breadcrumb');
if (breadcrumb) {
breadcrumb.innerHTML = ''; breadcrumb.innerHTML = '';
}
const path = app.breadcrumbPath; // [{id, name}, ...] const path = app.breadcrumbPath; // [{id, name}, ...]
// -- Home icon (always present, clickable to go to root) -- // -- Home icon (always present, clickable to go to root) --
@@ -558,7 +566,7 @@ const ui = {
loadFiles(); loadFiles();
}); });
} }
breadcrumb.appendChild(homeIcon); breadcrumb?.appendChild(homeIcon);
// -- Root/Home folder name (if available) is always the first element of the breadcrumb -- // -- Root/Home folder name (if available) is always the first element of the breadcrumb --
// TODO clarify the difference between homeIcon & this first element // TODO clarify the difference between homeIcon & this first element
@@ -579,7 +587,7 @@ const ui = {
const separator = document.createElement('span'); const separator = document.createElement('span');
separator.className = 'breadcrumb-separator'; separator.className = 'breadcrumb-separator';
separator.textContent = '>'; separator.textContent = '>';
breadcrumb.appendChild(separator); breadcrumb?.appendChild(separator);
// Segment item // Segment item
const item = document.createElement('span'); const item = document.createElement('span');
@@ -600,7 +608,7 @@ const ui = {
// can drag files on this folder // can drag files on this folder
// dragover – only folders are valid drop targets // dragover – only folders are valid drop targets
item.addEventListener('dragover', (e) => { item.addEventListener('dragover', (e) => {
const card = e.target.closest('span'); const card = /** @type {HTMLElement} */ (e.target).closest('span');
if (!card?.dataset.folderId) return; if (!card?.dataset.folderId) return;
e.preventDefault(); e.preventDefault();
card.classList.add('drop-target'); card.classList.add('drop-target');
@@ -609,14 +617,14 @@ const ui = {
// dragleave // dragleave
item.addEventListener('dragleave', (e) => { item.addEventListener('dragleave', (e) => {
console.log('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; if (!card?.dataset.folderId) return;
card.classList.remove('drop-target'); card.classList.remove('drop-target');
}); });
// drop – only folders accept drops // drop – only folders accept drops
item.addEventListener('drop', async (e) => { item.addEventListener('drop', async (e) => {
const card = e.target.closest('span'); const card = /** @type {HTMLElement} */ (e.target).closest('span');
if (!card) return; if (!card) return;
const targetFolderId = card.dataset.folderId; const targetFolderId = card.dataset.folderId;
if (!targetFolderId) return; if (!targetFolderId) return;
@@ -625,13 +633,15 @@ const ui = {
card.classList.remove('drop-target'); card.classList.remove('drop-target');
const action = e.dataTransfer?.dropEffect; const action = e.dataTransfer?.dropEffect;
if (action) {
await this._dropToFolder(action, targetFolderId, e.dataTransfer); await this._dropToFolder(action, targetFolderId, e.dataTransfer);
}
}); });
} else { } else {
// Last segment: current location, not clickable // Last segment: current location, not clickable
item.classList.add('breadcrumb-current'); item.classList.add('breadcrumb-current');
} }
breadcrumb.appendChild(item); breadcrumb?.appendChild(item);
}); });
}, },
@@ -915,7 +925,7 @@ const ui = {
parent_id: card.dataset.parentId || '' parent_id: card.dataset.parentId || ''
}; };
} else { } else {
const fileData = info.data || self._items.get(info.id); const fileData = info.data || this._items.get(info.id);
app.contextMenuTargetFile = { app.contextMenuTargetFile = {
id: info.id, id: info.id,
name: card.dataset.fileName, name: card.dataset.fileName,
@@ -927,27 +937,27 @@ const ui = {
// ── click (open / navigate; select only via checkbox) ── // ── click (open / navigate; select only via checkbox) ──
filesList.addEventListener('click', (e) => { filesList.addEventListener('click', (e) => {
const card = e.target.closest('.file-item'); const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
if (!card) return; if (!card) return;
if (e.target.closest('.file-actions')) { if (/** @type {HTMLElement} */ (e.target).closest('.file-actions')) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
const info = itemInfo(card); const info = itemInfo(card);
if (!info) return; if (!info) return;
setContextTarget(card, info); setContextTarget(card, info);
const menuId = info.type === 'folder' ? 'folder-context-menu' : 'file-context-menu'; 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; return;
} }
if (e.target.closest('.checkbox-cell')) { if (/** @type {HTMLElement} */ (e.target).closest('.checkbox-cell')) {
toggleCardSelection(card, e); toggleCardSelection(card, e);
return; return;
} }
// Favorite star – handled by direct onclick on the button // 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) // Single-click opens/navigates (selection is only via checkbox)
const info = itemInfo(card); const info = itemInfo(card);
@@ -984,7 +994,7 @@ const ui = {
// ── shared events ────────────────────── // ── shared events ──────────────────────
filesList.addEventListener('contextmenu', (e) => { filesList.addEventListener('contextmenu', (e) => {
const card = e.target.closest('.file-item'); const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
if (!card) return; if (!card) return;
e.preventDefault(); e.preventDefault();
const info = itemInfo(card); const info = itemInfo(card);
@@ -1001,14 +1011,16 @@ const ui = {
if (contextMenus && typeof contextMenus.syncAddToPlaylistOption === 'function') { if (contextMenus && typeof contextMenus.syncAddToPlaylistOption === 'function') {
contextMenus.syncAddToPlaylistOption(); contextMenus.syncAddToPlaylistOption();
} }
if (menu) {
menu.style.left = `${e.pageX}px`; menu.style.left = `${e.pageX}px`;
menu.style.top = `${e.pageY}px`; menu.style.top = `${e.pageY}px`;
menu?.classList.remove('hidden'); menu.classList.remove('hidden');
}
}); });
// dragstart // dragstart
filesList.addEventListener('dragstart', (e) => { filesList.addEventListener('dragstart', (e) => {
const card = e.target.closest('.file-item'); const card = /** @type {HTMLElement} */ (e.target).closest('.file-item');
if (!card) { if (!card) {
e.preventDefault(); e.preventDefault();
return; return;
@@ -1020,6 +1032,8 @@ const ui = {
return; return;
} }
if (!e.dataTransfer) return;
e.dataTransfer.setData('text/plain', info.id); e.dataTransfer.setData('text/plain', info.id);
if (info.type === 'folder') { if (info.type === 'folder') {
e.dataTransfer.setData('application/oxicloud-folder', 'true'); e.dataTransfer.setData('application/oxicloud-folder', 'true');
@@ -1076,7 +1090,7 @@ const ui = {
// if more than maxElements display the fading // if more than maxElements display the fading
if (selectedCardFromList.length > maxElements) { if (selectedCardFromList.length > maxElements) {
lastItemDiv.classList.add('fading'); lastItemDiv?.classList.add('fading');
} }
this.dragPreview.appendChild(this.draggedItems); this.dragPreview.appendChild(this.draggedItems);
@@ -1093,7 +1107,7 @@ const ui = {
// dragover – only folders are valid drop targets // dragover – only folders are valid drop targets
filesList.addEventListener('dragover', (e) => { 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 || card.dataset.fileId) return;
if (!card.dataset.folderId) return; if (!card.dataset.folderId) return;
e.preventDefault(); e.preventDefault();
@@ -1102,14 +1116,14 @@ const ui = {
// dragleave // dragleave
filesList.addEventListener('dragleave', (e) => { 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; if (!card || card.dataset.fileId) return;
card.classList.remove('drop-target'); card.classList.remove('drop-target');
}); });
// drop – only folders accept drops // drop – only folders accept drops
filesList.addEventListener('drop', async (e) => { 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; if (!card || card.dataset.fileId) return;
const targetFolderId = card.dataset.folderId; const targetFolderId = card.dataset.folderId;
if (!targetFolderId) return; if (!targetFolderId) return;
@@ -1117,6 +1131,7 @@ const ui = {
e.preventDefault(); e.preventDefault();
card.classList.remove('drop-target'); card.classList.remove('drop-target');
if (!e.dataTransfer) return;
const action = e.dataTransfer.dropEffect; const action = e.dataTransfer.dropEffect;
await this._dropToFolder(action, targetFolderId, e.dataTransfer); await this._dropToFolder(action, targetFolderId, e.dataTransfer);
}); });
@@ -1203,7 +1218,7 @@ const ui = {
const targetPath = isFavorite ? filledPath : outlinePath; const targetPath = isFavorite ? filledPath : outlinePath;
if (svg && targetPath) { if (svg && targetPath) {
const p = svg.querySelector('path'); 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`); 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 * Render an array of folders into both grid and list views
* using DocumentFragment for minimal reflows. * using DocumentFragment for minimal reflows.
*
* @param {FolderInfo[]} folders
*/ */
renderFolders(folders) { renderFolders(folders) {
if (!this._delegationReady) this.initDelegation(); if (!this._delegationReady) this.initDelegation();
@@ -1503,6 +1520,8 @@ function showContextMenuAtElement(triggerElement, menuId) {
menu.classList.remove('hidden'); menu.classList.remove('hidden');
} }
let __rubberBandJustFinished = false;
/** /**
* Rubber band (lasso) selection — click + drag on empty grid area * Rubber band (lasso) selection — click + drag on empty grid area
* to draw a rectangle and select all cards it touches. * 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) * @param {boolean} [options.danger=false] - Use danger styling (red)
* @returns {Promise<boolean>} true if confirmed, false if cancelled * @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 ct = confirmText || i18n.t('actions.delete');
const cc = cancelText || i18n.t('actions.cancel'); const cc = cancelText || i18n.t('actions.cancel');
const t = title || i18n.t('dialogs.confirm_title'); const t = title || i18n.t('dialogs.confirm_title');
@@ -1674,8 +1693,8 @@ function showConfirmDialog({ title, message, confirmText, cancelText, danger = t
resolve(result); resolve(result);
}; };
overlay.querySelector('.confirm-dialog-cancel').addEventListener('click', () => cleanup(false)); overlay.querySelector('.confirm-dialog-cancel')?.addEventListener('click', () => cleanup(false));
overlay.querySelector('.confirm-dialog-ok').addEventListener('click', () => cleanup(true)); overlay.querySelector('.confirm-dialog-ok')?.addEventListener('click', () => cleanup(true));
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) cleanup(false); if (e.target === overlay) cleanup(false);
}); });
+46 -23
View File
@@ -5,21 +5,10 @@
import { isTextViewable } from '../core/formatters.js'; import { isTextViewable } from '../core/formatters.js';
const uiFileTypes = { /** @import {FileInfo} from '../core/types.js' */
// TODO: 'd better to use a canViw() method in inlineViewer
isViewableFile(file) {
if (!file?.mime_type) return false;
if (file.mime_type.startsWith('image/')) return true;
if (file.mime_type === 'application/pdf') return true;
if (file.mime_type.startsWith('audio/')) return true;
if (file.mime_type.startsWith('video/')) return true;
return isTextViewable(file.mime_type);
},
getIconClass(fileName) { /** @type {Record<string, string>} */
if (!fileName) return 'fas fa-file'; const ICON_CLASS_MAP = {
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf: 'fas fa-file-pdf', pdf: 'fas fa-file-pdf',
doc: 'fas fa-file-word', doc: 'fas fa-file-word',
docx: 'fas fa-file-word', docx: 'fas fa-file-word',
@@ -71,14 +60,10 @@ const uiFileTypes = {
bash: 'fas fa-terminal', bash: 'fas fa-terminal',
bat: 'fas fa-terminal', bat: 'fas fa-terminal',
md: 'fas fa-file-alt' md: 'fas fa-file-alt'
}; };
return map[ext] || 'fas fa-file';
},
getIconSpecialClass(fileName) { /** @type {Record<string, string>} */
if (!fileName) return ''; const ICON_SPECIAL_CLASS_MAP = {
const ext = (fileName.split('.').pop() || '').toLowerCase();
const map = {
pdf: 'pdf-icon', pdf: 'pdf-icon',
doc: 'doc-icon', doc: 'doc-icon',
docx: 'doc-icon', docx: 'doc-icon',
@@ -167,8 +152,46 @@ const uiFileTypes = {
bat: 'script-icon', bat: 'script-icon',
md: 'code-icon md-icon', md: 'code-icon md-icon',
txt: 'doc-icon' txt: 'doc-icon'
}; };
return map[ext] || '';
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;
if (file.mime_type === 'application/pdf') return true;
if (file.mime_type.startsWith('audio/')) return true;
if (file.mime_type.startsWith('video/')) return true;
return isTextViewable(file.mime_type);
},
/**
*
* @param {string} fileName
* @returns {string}
*/
getIconClass(fileName) {
if (!fileName) return 'fas fa-file';
const ext = (fileName.split('.').pop() || '').toLowerCase();
return ICON_CLASS_MAP[ext] || 'fas fa-file';
},
/**
*
* @param {string} fileName
* @returns
*/
getIconSpecialClass(fileName) {
if (!fileName) return '';
const ext = (fileName.split('.').pop() || '').toLowerCase();
return ICON_SPECIAL_CLASS_MAP[ext] || '';
} }
}; };
+6
View File
@@ -6,6 +6,12 @@
import { notifications } from '../core/notifications.js'; import { notifications } from '../core/notifications.js';
const uiNotifications = { const uiNotifications = {
/**
*
* @param {string} title
* @param {string} message
* @returns
*/
show(title, message) { show(title, message) {
const normalizedTitle = String(title || '').toLowerCase(); const normalizedTitle = String(title || '').toLowerCase();
let icon = 'fa-info-circle'; let icon = 'fa-info-circle';
+4 -4
View File
@@ -49,7 +49,7 @@ function setupUserMenu() {
}); });
document.addEventListener('click', (e) => { 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'); wrapper.classList.remove('open');
} }
}); });
@@ -137,7 +137,7 @@ function setupUserMenu() {
const aboutOverlay = document.getElementById('about-modal-overlay'); const aboutOverlay = document.getElementById('about-modal-overlay');
if (aboutCloseBtn) { if (aboutCloseBtn) {
aboutCloseBtn.addEventListener('click', () => { aboutCloseBtn.addEventListener('click', () => {
aboutOverlay.classList.add('hidden'); aboutOverlay?.classList.add('hidden');
}); });
} }
if (aboutOverlay) { if (aboutOverlay) {
@@ -242,7 +242,7 @@ function showUserProfileModal() {
`; `;
// Set dynamic bar width and color via JS property (CSP-safe) // 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) { if (barFill) {
barFill.style.width = `${percentage}%`; barFill.style.width = `${percentage}%`;
barFill.style.background = barColor; barFill.style.background = barColor;
@@ -251,7 +251,7 @@ function showUserProfileModal() {
document.body.appendChild(overlay); document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show')); requestAnimationFrame(() => overlay.classList.add('show'));
overlay.querySelector('#profile-modal-close').addEventListener('click', () => { overlay.querySelector('#profile-modal-close')?.addEventListener('click', () => {
overlay.classList.remove('show'); overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 200); setTimeout(() => overlay.remove(), 200);
}); });
+38 -6
View File
@@ -3,11 +3,21 @@
* Centralized global helpers for date/size/text formatting and XSS-safe escaping. * Centralized global helpers for date/size/text formatting and XSS-safe escaping.
*/ */
/**
*
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) { function escapeHtml(str) {
if (typeof str !== 'string') return ''; if (typeof str !== 'string') return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;'); 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) { function formatFileSize(bytes) {
if (bytes === 0) return '0 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). /// Formats a byte count for quota display. When bytes is 0, returns "∞" (unlimited).
/**
*
* @param {number} bytes
* @returns {string}
*/
function formatQuotaSize(bytes) { function formatQuotaSize(bytes) {
if (bytes === 0) return '∞'; if (bytes === 0) return '∞';
return formatFileSize(bytes); return formatFileSize(bytes);
} }
/**
*
* @param {Date | number| null} value
* @returns {string}
*/
function formatDateTime(value) { function formatDateTime(value) {
if (!value) return ''; if (!value) return '';
let dateValue; let dateValue;
@@ -38,6 +58,11 @@ function formatDateTime(value) {
return `${dateValue.toLocaleDateString()} ${dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; return `${dateValue.toLocaleDateString()} ${dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
} }
/**
*
* @param {Date | number| null} value
* @returns {string}
*/
function formatDateShort(value) { function formatDateShort(value) {
if (!value) return 'N/A'; if (!value) return 'N/A';
const dateValue = typeof value === 'number' ? new Date(value * 1000) : new Date(value); const dateValue = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
@@ -49,10 +74,7 @@ function formatDateShort(value) {
}); });
} }
function isTextViewable(mimeType) { const TEXT_TYPES = [
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
const textTypes = [
'application/json', 'application/json',
'application/xml', 'application/xml',
'application/javascript', 'application/javascript',
@@ -61,8 +83,18 @@ function isTextViewable(mimeType) {
'application/toml', 'application/toml',
'application/x-toml', 'application/x-toml',
'application/sql' 'application/sql'
]; ];
return textTypes.includes(mimeType); // FIXME: move is to another file
/**
*
* @param {string} mimeType
* @returns {boolean}
*/
function isTextViewable(mimeType) {
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
return TEXT_TYPES.includes(mimeType);
} }
export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isTextViewable }; export { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatQuotaSize, isTextViewable };
+1
View File
@@ -18,6 +18,7 @@ if (!supportedLocales.includes(currentLocale)) {
} }
// Cache for translations // Cache for translations
/** @type {Record<string, Object>} */
const translations = {}; const translations = {};
/** /**
+1 -1
View File
@@ -450,7 +450,7 @@ function replaceIconsInElement(container) {
if (!container) container = document.body; if (!container) container = document.body;
const icons = container.querySelectorAll('i[class*="fa-"]'); const icons = container.querySelectorAll('i[class*="fa-"]');
for (let i = 0; i < icons.length; i++) { for (let i = 0; i < icons.length; i++) {
const el = icons[i]; const el = /** @type {HTMLElement} */ (icons[i]);
const classes = el.className.split(/\s+/); const classes = el.className.split(/\s+/);
// Find the icon name (fa-xxx) // 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.className = `language-option${lang.code === currentLocale ? ' active' : ''}`;
option.setAttribute('role', 'option'); option.setAttribute('role', 'option');
option.setAttribute('data-lang', lang.code); option.setAttribute('data-lang', lang.code);
option.setAttribute('aria-selected', lang.code === currentLocale); option.setAttribute('aria-selected', String(lang.code === currentLocale));
option.innerHTML = ` option.innerHTML = `
<span class="lang-flag">${lang.flag}</span> <span class="lang-flag">${lang.flag}</span>
<span class="lang-name">${lang.name}</span> <span class="lang-name">${lang.name}</span>
@@ -134,7 +134,7 @@ function createLanguageSelector(containerId = 'language-selector') {
// Close dropdown when clicking outside // Close dropdown when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!container.contains(e.target)) { if (!container.contains(/** @type {Node | null } */ (e.target))) {
closeDropdown(container); closeDropdown(container);
} }
}); });
+4 -4
View File
@@ -53,9 +53,9 @@ const Modal = {
this.closeBtn = document.getElementById('modal-close-btn'); this.closeBtn = document.getElementById('modal-close-btn');
// Event listeners // Event listeners
this.cancelBtn.addEventListener('click', () => this.close(false)); this.cancelBtn?.addEventListener('click', () => this.close(false));
this.closeBtn.addEventListener('click', () => this.close(false)); this.closeBtn?.addEventListener('click', () => this.close(false));
this.confirmBtn.addEventListener('click', () => this.confirm()); this.confirmBtn?.addEventListener('click', () => this.confirm());
// Close on overlay click // Close on overlay click
this.overlay.addEventListener('click', (e) => { this.overlay.addEventListener('click', (e) => {
@@ -65,7 +65,7 @@ const Modal = {
}); });
// Handle Enter and Escape keys // Handle Enter and Escape keys
this.input.addEventListener('keydown', (e) => { this.input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.confirm(); this.confirm();
+5 -5
View File
@@ -34,7 +34,7 @@ const notifications = (() => {
bellBtn.addEventListener('click', (e) => { bellBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const open = wrapper.classList.toggle('open'); const open = wrapper?.classList.toggle('open');
bellBtn.classList.toggle('active', open); bellBtn.classList.toggle('active', open);
// Close user-menu if it's open // Close user-menu if it's open
@@ -46,7 +46,7 @@ const notifications = (() => {
// Close on outside click // Close on outside click
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) { if (!wrapper?.contains(/** @type {Node | null} */ (e.target))) {
close(); close();
} }
}); });
@@ -63,8 +63,8 @@ const notifications = (() => {
function close() { function close() {
const bellBtn = $('notif-bell-btn'); const bellBtn = $('notif-bell-btn');
const wrapper = $('notif-wrapper'); const wrapper = $('notif-wrapper');
wrapper.classList.remove('open'); wrapper?.classList.remove('open');
bellBtn.classList.remove('active'); bellBtn?.classList.remove('active');
} }
/* ── badge helpers ──────────────────────────────────────── */ /* ── badge helpers ──────────────────────────────────────── */
@@ -82,7 +82,7 @@ const notifications = (() => {
if (!badge) return; if (!badge) return;
if (_badgeCount > 0) { if (_badgeCount > 0) {
badge.classList.remove('hidden'); badge.classList.remove('hidden');
badge.textContent = _badgeCount > 99 ? '99+' : _badgeCount; badge.textContent = _badgeCount > 99 ? '99+' : String(_badgeCount);
} else { } else {
badge.classList.add('hidden'); 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 pickerName = document.getElementById('lang-picker-name');
const searchInput = document.getElementById('lang-picker-search-input'); const searchInput = document.getElementById('lang-picker-search-input');
if (!languagePanel || !picker) return; if (!languagePanel || !picker || !pickerFlag || !pickerName || !pickerList) return;
// --- Auto-detect browser language --- // --- Auto-detect browser language ---
const detected = detectBrowserLanguage(); const detected = detectBrowserLanguage();
+1 -1
View File
@@ -271,7 +271,7 @@ const fileOps = {
/** /**
* Upload files to the server with real-time progress indication * 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) { async uploadFiles(files) {
const originalFiles = Array.from(files || []); const originalFiles = Array.from(files || []);
+6 -5
View File
@@ -146,14 +146,14 @@ const search = {
if (previousSearchHeader) { if (previousSearchHeader) {
previousSearchHeader.replaceWith(searchHeader); previousSearchHeader.replaceWith(searchHeader);
} else { } else {
pageStickyHeader.appendChild(searchHeader); pageStickyHeader?.appendChild(searchHeader);
} }
// Sort dropdown — re-searches with new sort order (server-side) // 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) { if (sortSelect) {
sortSelect.addEventListener('change', () => { sortSelect.addEventListener('change', () => {
const searchInput = document.querySelector('.search-container input'); const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input'));
if (searchInput?.value.trim()) { if (searchInput?.value.trim()) {
const event = new CustomEvent('search-resort', { const event = new CustomEvent('search-resort', {
detail: { sort_by: sortSelect.value } detail: { sort_by: sortSelect.value }
@@ -167,11 +167,12 @@ const search = {
const clearSearchBtn = document.getElementById('clear-search-btn'); const clearSearchBtn = document.getElementById('clear-search-btn');
if (clearSearchBtn) { if (clearSearchBtn) {
clearSearchBtn.addEventListener('click', () => { clearSearchBtn.addEventListener('click', () => {
document.querySelector('.search-container input').value = ''; const searchInput = /** @type {HTMLInputElement} */ (document.querySelector('.search-container input'));
searchInput.value = '';
app.currentPath = ''; app.currentPath = '';
app.isSearchMode = false; app.isSearchMode = false;
document.querySelector('.search-results-header')?.remove(); document.querySelector('.search-results-header')?.remove();
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
loadFiles(); loadFiles();
}); });
} }
+2 -3
View File
@@ -6,6 +6,7 @@
*/ */
import { loadFiles } from '../../app/filesView.js'; import { loadFiles } from '../../app/filesView.js';
import { ui } from '../../app/ui.js';
class WopiEditor { class WopiEditor {
constructor() { constructor() {
@@ -46,9 +47,7 @@ class WopiEditor {
window.open(hostUrl, '_blank'); window.open(hostUrl, '_blank');
} catch (error) { } catch (error) {
console.error('Failed to open WOPI editor in tab:', error); console.error('Failed to open WOPI editor in tab:', error);
if (window.showNotification) { ui.showNotification('Could not open the document editor.', 'error');
window.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 // FIXME: this case is not easy to understand, should apply better implementation
multiSelect.init(); multiSelect.init();
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
if (this._cache.size === 0) { if (this._cache.size === 0) {
ui.showError(` ui.showError(`
+22 -7
View File
@@ -214,7 +214,7 @@ const photosView = {
if (grid?.classList.contains('photos-grid')) { if (grid?.classList.contains('photos-grid')) {
grid.insertAdjacentHTML('beforeend', tilesHtml); grid.insertAdjacentHTML('beforeend', tilesHtml);
const countSpan = existingHeader.querySelector('.photos-day-count'); const countSpan = existingHeader.querySelector('.photos-day-count');
if (countSpan) countSpan.textContent = grid.children.length; if (countSpan) countSpan.textContent = String(grid.children.length);
} }
} else { } else {
// New group — insert header + grid before sentinel // New group — insert header + grid before sentinel
@@ -269,11 +269,14 @@ const photosView = {
/** @param {number} [startIndex=0] When > 0, only process video tiles /** @param {number} [startIndex=0] When > 0, only process video tiles
* for items[startIndex..] — avoids re-scanning the entire DOM. */ * for items[startIndex..] — avoids re-scanning the entire DOM. */
_setupVideoThumbnails(startIndex = 0) { _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; const newIds = startIndex > 0 ? new Set(this.items.slice(startIndex).map((f) => f.id)) : null;
if (!tiles) return;
for (const tile of tiles) { for (const tile of tiles) {
const fileId = tile.dataset.id; const fileId = tile.dataset.id;
if (!fileId) continue;
if (newIds && !newIds.has(fileId)) continue; if (newIds && !newIds.has(fileId)) continue;
if (this._videoThumbCache.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 /** Extract a single frame from a video and display it as the tile
* thumbnail, then upload the JPEG to the server for caching. */ * thumbnail, then upload the JPEG to the server for caching. */
_generateVideoThumbnail(tile, img) { _generateVideoThumbnail(tile, img) {
// TODO: use thumbnail.js s common lib
const fileId = tile.dataset.id; const fileId = tile.dataset.id;
const video = document.createElement('video'); const video = document.createElement('video');
video.crossOrigin = 'anonymous'; video.crossOrigin = 'anonymous';
@@ -341,7 +346,7 @@ const photosView = {
canvas.width = Math.round(video.videoWidth * scale); canvas.width = Math.round(video.videoWidth * scale);
canvas.height = Math.round(video.videoHeight * scale); canvas.height = Math.round(video.videoHeight * scale);
const ctx = canvas.getContext('2d'); 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, // JPEG: explicit quality control, universally supported,
// and server stores as-is when dimensions fit (zero re-encode). // and server stores as-is when dimensions fit (zero re-encode).
@@ -362,7 +367,7 @@ const photosView = {
// Upload to server for permanent caching // Upload to server for permanent caching
const token = localStorage.getItem('token') || sessionStorage.getItem('token'); 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}`; if (token) headers.Authorization = `Bearer ${token}`;
fetch(`/api/files/${fileId}/thumbnail/preview`, { fetch(`/api/files/${fileId}/thumbnail/preview`, {
@@ -426,6 +431,7 @@ const photosView = {
/** Render empty state */ /** Render empty state */
_renderEmpty() { _renderEmpty() {
if (!this._container) return;
this._container.innerHTML = ` this._container.innerHTML = `
<div class="photos-empty"> <div class="photos-empty">
<i class="fas fa-images"></i> <i class="fas fa-images"></i>
@@ -526,15 +532,20 @@ const photosView = {
<button id="photos-sel-clear" title="Clear"><i class="fas fa-times"></i></button> <button id="photos-sel-clear" title="Clear"><i class="fas fa-times"></i></button>
`; `;
bar.querySelector('#photos-sel-clear').onclick = () => { const bar_clear = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-clear'));
if (bar_clear) {
bar_clear.onclick = () => {
this.selected.clear(); this.selected.clear();
this._container.querySelectorAll('.photo-tile.selected').forEach((t) => { this._container.querySelectorAll('.photo-tile.selected').forEach((t) => {
t.classList.remove('selected'); t.classList.remove('selected');
}); });
this._hideSelectionBar(); this._hideSelectionBar();
}; };
}
bar.querySelector('#photos-sel-delete').onclick = async () => { const bar_delete = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-delete'));
if (bar_delete) {
bar_delete.onclick = async () => {
if (!confirm('Delete selected items?')) return; if (!confirm('Delete selected items?')) return;
for (const fid of this.selected) { for (const fid of this.selected) {
try { try {
@@ -553,8 +564,11 @@ const photosView = {
this._renderedCount = 0; this._renderedCount = 0;
this._renderFull(); this._renderFull();
}; };
}
bar.querySelector('#photos-sel-download').onclick = async () => { const bar_download = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-download'));
if (bar_download) {
bar_download.onclick = async () => {
for (const fid of this.selected) { for (const fid of this.selected) {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = `/api/files/${fid}`; a.href = `/api/files/${fid}`;
@@ -564,6 +578,7 @@ const photosView = {
a.remove(); a.remove();
} }
}; };
}
bar.style.display = 'flex'; bar.style.display = 'flex';
}, },
+1 -1
View File
@@ -109,7 +109,7 @@ const recent = {
multiSelect.clear(); multiSelect.clear();
multiSelect.init(); // this will wire buttons & select-all-checkbox multiSelect.init(); // this will wire buttons & select-all-checkbox
} }
ui.updateBreadcrumb(''); ui.updateBreadcrumb();
if (recentItems.length === 0) { if (recentItems.length === 0) {
ui.showError(` ui.showError(`
+2 -2
View File
@@ -165,7 +165,7 @@ const fileSharing = {
/** /**
* Format expiration date for display (Unix timestamp in seconds or ISO string) * Format expiration date for display (Unix timestamp in seconds or ISO string)
* @param {number|string} value * @param {number|Date} value
* @returns {string} * @returns {string}
*/ */
formatExpirationDate(value) { formatExpirationDate(value) {
@@ -177,7 +177,7 @@ const fileSharing = {
* Send a notification about a shared resource (stub — no backend endpoint yet) * Send a notification about a shared resource (stub — no backend endpoint yet)
* @param {string} shareUrl * @param {string} shareUrl
* @param {string} recipientEmail * @param {string} recipientEmail
* @param {string} message * @param {string} _message
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async sendShareNotification(shareUrl, recipientEmail, _message = '') { async sendShareNotification(shareUrl, recipientEmail, _message = '') {
@@ -3,7 +3,7 @@ import { getCsrfHeaders } from '../../core/csrf.js';
(() => { (() => {
var API_BASE = window.location.origin; 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 deviceInfo = document.getElementById('device-info');
var actionButtons = document.getElementById('action-buttons'); var actionButtons = document.getElementById('action-buttons');
var errorText = document.getElementById('error-text'); 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 errorMessage = document.getElementById('error-message');
var errorAction = document.getElementById('error-action'); var errorAction = document.getElementById('error-action');
if (!errorTitle || !errorMessage || !errorAction) {
throw new Error('missing html elements');
}
switch (errorType) { switch (errorType) {
case 'invalid-credentials': case 'invalid-credentials':
errorTitle.textContent = 'Login Failed'; 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 { i18n } from '../../core/i18n.js';
import { fileSharing } from '../../features/sharing/fileSharing.js'; import { fileSharing } from '../../features/sharing/fileSharing.js';
/** @import {Share} from '../../core/types.js' */
const TTL = 5 * 60 * 1000; // 5 min const TTL = 5 * 60 * 1000; // 5 min
const sharedView = { const sharedView = {
// State // State
/** @type {Array<Share>} */
items: [], items: [],
_expires: 0, _expires: 0,
@@ -21,6 +25,7 @@ const sharedView = {
/** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */ /** @type {Map<string, boolean>} key = "file:<id>" | "folder:<id>" */
_knownItemsId: new Map(), _knownItemsId: new Map(),
/** @type {Array<Share>} */
filteredItems: [], filteredItems: [],
currentItem: null, currentItem: null,
@@ -67,7 +72,7 @@ const sharedView = {
* *
* @param {boolean} force ignore cache * @param {boolean} force ignore cache
*/ */
async loadItems(force) { async loadItems(force = false) {
if (this._expires > Date.now() && !force) return; if (this._expires > Date.now() && !force) return;
try { try {