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:
+10
-2
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}%`;
|
||||
|
||||
+14
-11
@@ -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);
|
||||
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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
+65
-46
@@ -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');
|
||||
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;
|
||||
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();
|
||||
}
|
||||
if (menu) {
|
||||
menu.style.left = `${e.pageX}px`;
|
||||
menu.style.top = `${e.pageY}px`;
|
||||
menu?.classList.remove('hidden');
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -5,21 +5,10 @@
|
||||
|
||||
import { isTextViewable } from '../core/formatters.js';
|
||||
|
||||
const uiFileTypes = {
|
||||
// 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);
|
||||
},
|
||||
/** @import {FileInfo} from '../core/types.js' */
|
||||
|
||||
getIconClass(fileName) {
|
||||
if (!fileName) return 'fas fa-file';
|
||||
const ext = (fileName.split('.').pop() || '').toLowerCase();
|
||||
const map = {
|
||||
/** @type {Record<string, string>} */
|
||||
const ICON_CLASS_MAP = {
|
||||
pdf: 'fas fa-file-pdf',
|
||||
doc: 'fas fa-file-word',
|
||||
docx: 'fas fa-file-word',
|
||||
@@ -72,13 +61,9 @@ const uiFileTypes = {
|
||||
bat: 'fas fa-terminal',
|
||||
md: 'fas fa-file-alt'
|
||||
};
|
||||
return map[ext] || 'fas fa-file';
|
||||
},
|
||||
|
||||
getIconSpecialClass(fileName) {
|
||||
if (!fileName) return '';
|
||||
const ext = (fileName.split('.').pop() || '').toLowerCase();
|
||||
const map = {
|
||||
/** @type {Record<string, string>} */
|
||||
const ICON_SPECIAL_CLASS_MAP = {
|
||||
pdf: 'pdf-icon',
|
||||
doc: 'doc-icon',
|
||||
docx: 'doc-icon',
|
||||
@@ -168,7 +153,45 @@ const uiFileTypes = {
|
||||
md: 'code-icon md-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,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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -3,11 +3,21 @@
|
||||
* Centralized global helpers for date/size/text formatting and XSS-safe escaping.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
@@ -19,11 +29,21 @@ function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
/// Formats a byte count for quota display. When bytes is 0, returns "∞" (unlimited).
|
||||
/**
|
||||
*
|
||||
* @param {number} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatQuotaSize(bytes) {
|
||||
if (bytes === 0) return '∞';
|
||||
return formatFileSize(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Date | number| null} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
let dateValue;
|
||||
@@ -38,6 +58,11 @@ function formatDateTime(value) {
|
||||
return `${dateValue.toLocaleDateString()} ${dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Date | number| null} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDateShort(value) {
|
||||
if (!value) return 'N/A';
|
||||
const dateValue = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||
@@ -49,10 +74,7 @@ function formatDateShort(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function isTextViewable(mimeType) {
|
||||
if (!mimeType) return false;
|
||||
if (mimeType.startsWith('text/')) return true;
|
||||
const textTypes = [
|
||||
const TEXT_TYPES = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'application/javascript',
|
||||
@@ -62,7 +84,17 @@ function isTextViewable(mimeType) {
|
||||
'application/x-toml',
|
||||
'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 };
|
||||
|
||||
@@ -18,6 +18,7 @@ if (!supportedLocales.includes(currentLocale)) {
|
||||
}
|
||||
|
||||
// Cache for translations
|
||||
/** @type {Record<string, Object>} */
|
||||
const translations = {};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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,15 +532,20 @@ const photosView = {
|
||||
<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._container.querySelectorAll('.photo-tile.selected').forEach((t) => {
|
||||
t.classList.remove('selected');
|
||||
});
|
||||
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;
|
||||
for (const fid of this.selected) {
|
||||
try {
|
||||
@@ -553,8 +564,11 @@ const photosView = {
|
||||
this._renderedCount = 0;
|
||||
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) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/files/${fid}`;
|
||||
@@ -564,6 +578,7 @@ const photosView = {
|
||||
a.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
bar.style.display = 'flex';
|
||||
},
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user