style(front/js): apply types on all objects
- reduce amount of warnings in IDE
- maximize API type mapping with static/js/core/types.js
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
|
||||
/**
|
||||
* @import {AuthResponse, RoleEnum, User} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
// API endpoints
|
||||
const API_URL = '/api/auth';
|
||||
const LOGIN_ENDPOINT = `${API_URL}/login`;
|
||||
@@ -41,6 +45,18 @@ function inputVal(id) {
|
||||
}
|
||||
|
||||
// Language selector texts (used before i18n is loaded)
|
||||
/**
|
||||
* @typedef {Object} PreTranslatedText
|
||||
* @property {string} title
|
||||
* @property {string} subtitle
|
||||
* @property {string} continue
|
||||
* @property {string} autodetected
|
||||
* @property {string} moreLanguages
|
||||
* @property {string} modalTitle
|
||||
* @property {string} searchPlaceholder
|
||||
*/
|
||||
|
||||
/** @type {Record<String,PreTranslatedText>} */
|
||||
const LANGUAGE_TEXTS = {
|
||||
en: {
|
||||
title: 'Welcome!',
|
||||
@@ -145,6 +161,16 @@ const LANGUAGE_TEXTS = {
|
||||
|
||||
// Complete language registry — add new languages here, they'll appear automatically
|
||||
// `popular: true` languages show as cards on the main screen, the rest in the modal
|
||||
/**
|
||||
* @typedef {Object} Lang
|
||||
* @property {string} code
|
||||
* @property {string} name
|
||||
* @property {string} nativeName
|
||||
* @property {string} flag
|
||||
* @property {boolean} popular
|
||||
*/
|
||||
|
||||
/** @type {Lang[]} */
|
||||
export const ALL_LANGUAGES = [
|
||||
{
|
||||
code: 'en',
|
||||
@@ -377,9 +403,18 @@ export const ALL_LANGUAGES = [
|
||||
// --- Panel visibility helpers ---
|
||||
// The `.hidden` CSS class uses `display: none !important`, so inline
|
||||
// `style.display` can never override it. Always toggle the class instead.
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
function showPanel(el) {
|
||||
if (el) el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
function hidePanel(el) {
|
||||
if (el) el.classList.add('hidden');
|
||||
}
|
||||
@@ -434,13 +469,18 @@ function detectBrowserLanguage() {
|
||||
return ALL_LANGUAGES[0]; // fallback to English
|
||||
}
|
||||
|
||||
// Build a language option element (card style)
|
||||
/**
|
||||
* Build a language option element (card style)
|
||||
* @param {Lang} lang
|
||||
* @param {boolean} isSelected
|
||||
* @returns
|
||||
*/
|
||||
function buildLanguageCard(lang, isSelected) {
|
||||
const item = document.createElement('div');
|
||||
item.className = `lang-picker-item${isSelected ? ' selected' : ''}`;
|
||||
item.setAttribute('data-lang', lang.code);
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('aria-selected', isSelected);
|
||||
item.setAttribute('aria-selected', String(isSelected));
|
||||
item.innerHTML = `
|
||||
<span class="lang-picker-item-flag">${lang.flag}</span>
|
||||
<span class="lang-picker-item-name">${lang.nativeName}</span>
|
||||
@@ -596,7 +636,10 @@ function initLanguageSelector() {
|
||||
});
|
||||
}
|
||||
|
||||
// Update language panel texts based on selected language
|
||||
/**
|
||||
* Update language panel texts based on selected language
|
||||
* @param {string} lang
|
||||
*/
|
||||
function updateLanguagePanelTexts(lang) {
|
||||
const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en;
|
||||
const titleEl = document.getElementById('language-title');
|
||||
@@ -1089,6 +1132,9 @@ if (isLoginPage && adminSetupForm) {
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @returns {Promise<AuthResponse>}
|
||||
*/
|
||||
async function login(username, password) {
|
||||
try {
|
||||
@@ -1126,8 +1172,9 @@ async function login(username, password) {
|
||||
|
||||
// Parse the JSON response
|
||||
try {
|
||||
/** @type {AuthResponse} */
|
||||
const data = await response.json();
|
||||
console.log('Login successful, received data');
|
||||
console.log(`Login successful for user id ${data.user.id}, received data`);
|
||||
return data;
|
||||
} catch (jsonError) {
|
||||
console.error('Error parsing login response:', jsonError);
|
||||
@@ -1141,6 +1188,11 @@ async function login(username, password) {
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {string} username
|
||||
* @param {string} email
|
||||
* @param {string} password
|
||||
* @param {RoleEnum} [role]
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
async function register(username, email, password, role = 'user') {
|
||||
try {
|
||||
@@ -1170,8 +1222,9 @@ async function register(username, email, password, role = 'user') {
|
||||
|
||||
// Parse the JSON response
|
||||
try {
|
||||
/** @type {User} */
|
||||
const data = await response.json();
|
||||
console.log('Registration successful, received data');
|
||||
console.log(`Registration successful, user created: ${data.id}, received data`);
|
||||
return data;
|
||||
} catch (jsonError) {
|
||||
console.error('Error parsing registration response:', jsonError);
|
||||
|
||||
@@ -20,10 +20,19 @@ import { inlineViewer } from './inlineViewer.js';
|
||||
import { multiSelect } from './multiSelect.js';
|
||||
import { wopiEditor } from './wopiEditor.js';
|
||||
|
||||
/**
|
||||
* @import {FolderItem, FileItem, ItemTypeEnum, Playlist} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
/** @type {EventListener | null} */
|
||||
let _moveDialogEscapeHandler = null;
|
||||
|
||||
// Context Menus Module
|
||||
const contextMenus = {
|
||||
/**
|
||||
* @param {string} optionId
|
||||
* @param {boolean} isFavorite
|
||||
*/
|
||||
_setFavoriteOptionLabel(optionId, isFavorite) {
|
||||
const option = document.getElementById(optionId);
|
||||
if (!option) return;
|
||||
@@ -308,11 +317,13 @@ const contextMenus = {
|
||||
// Note: We don't use stopPropagation because all Escape handlers are on document level
|
||||
// Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses
|
||||
if (!_moveDialogEscapeHandler) {
|
||||
_moveDialogEscapeHandler = (e) => {
|
||||
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
|
||||
this.closeMoveDialog();
|
||||
_moveDialogEscapeHandler = /** @type {EventListener} */ (
|
||||
(/** @type {KeyboardEvent} */ e) => {
|
||||
if (e.key === 'Escape' && !moveFileDialog?.classList.contains('hidden')) {
|
||||
this.closeMoveDialog();
|
||||
}
|
||||
}
|
||||
};
|
||||
);
|
||||
document.addEventListener('keydown', _moveDialogEscapeHandler);
|
||||
}
|
||||
|
||||
@@ -385,8 +396,8 @@ const contextMenus = {
|
||||
|
||||
/**
|
||||
* Show move dialog for a file or folder
|
||||
* @param {Object} item - File or folder object
|
||||
* @param {string} mode - 'file' or 'folder'
|
||||
* @param {FolderItem | FileItem} item - File or folder object
|
||||
* @param {ItemTypeEnum} mode
|
||||
*/
|
||||
async showMoveDialog(item, mode) {
|
||||
// Set mode
|
||||
@@ -405,15 +416,15 @@ const contextMenus = {
|
||||
// Start at the parent of the item being moved (so user sees siblings and can navigate)
|
||||
let startFolderId = null;
|
||||
let startFolderName = null;
|
||||
if (mode === 'file' && item.folder_id) {
|
||||
startFolderId = item.folder_id;
|
||||
if (mode === 'file' && /** @type {FileItem} */ (item).folder_id) {
|
||||
startFolderId = /** @type {FileItem} */ (item).folder_id;
|
||||
// We need the folder name for breadcrumb - try to get it from current view
|
||||
const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`);
|
||||
if (folderEl) {
|
||||
startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null;
|
||||
}
|
||||
} else if (mode === 'folder' && item.parent_id) {
|
||||
startFolderId = item.parent_id;
|
||||
} else if (mode === 'folder' && /** @type {FolderItem} */ (item).parent_id) {
|
||||
startFolderId = /** @type {FolderItem} */ (item).parent_id;
|
||||
} else {
|
||||
// If item is at root level, start at user's home folder
|
||||
startFolderId = app.userHomeFolderId || null;
|
||||
@@ -492,6 +503,7 @@ const contextMenus = {
|
||||
|
||||
// The contents endpoint returns an array of child folders
|
||||
// The fallback /api/folders returns root folders (home folder itself)
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = Array.isArray(data) ? data : data.folders || [];
|
||||
console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders);
|
||||
|
||||
@@ -623,6 +635,9 @@ const contextMenus = {
|
||||
|
||||
/**
|
||||
* Render breadcrumb navigation for move dialog
|
||||
* @param {HTMLElement | null} container
|
||||
* @param {Array<{id: string, name: string}>} breadcrumb
|
||||
* @param {string | null} _currentFolderId
|
||||
*/
|
||||
_renderMoveDialogBreadcrumb(container, breadcrumb, _currentFolderId) {
|
||||
if (!container) return;
|
||||
@@ -666,7 +681,7 @@ const contextMenus = {
|
||||
}
|
||||
|
||||
// Breadcrumb path
|
||||
breadcrumb.forEach((segment, index) => {
|
||||
breadcrumb.forEach((/** @type {{id: string, name: string}} */ segment, /** @type {number} */ index) => {
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'move-breadcrumb-separator';
|
||||
separator.textContent = '>';
|
||||
@@ -709,8 +724,8 @@ const contextMenus = {
|
||||
|
||||
/**
|
||||
* Show share dialog for files or folders
|
||||
* @param {Object} item - File or folder object
|
||||
* @param {string} itemType - 'file' or 'folder'
|
||||
* @param {FileItem | FolderItem} item - File or folder object
|
||||
* @param {ItemTypeEnum} itemType
|
||||
*/
|
||||
async showShareDialog(item, itemType) {
|
||||
try {
|
||||
@@ -835,7 +850,7 @@ const contextMenus = {
|
||||
btn.closest('.existing-share-item').remove();
|
||||
if (existingSharesContainer.children.length === 0) {
|
||||
document.getElementById('existing-shares-section').classList.add('hidden');
|
||||
ui.setSharedVisualState(item.id, item.type, false);
|
||||
ui.setSharedVisualState(item.id, itemType, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -920,7 +935,7 @@ const contextMenus = {
|
||||
}
|
||||
|
||||
// Update Item's shared badge
|
||||
ui.setSharedVisualState(item.id, item.type, true);
|
||||
ui.setSharedVisualState(item.id, itemType, true);
|
||||
|
||||
// Show success message
|
||||
ui.showNotification(i18n.t('notifications.link_created'), i18n.t('notifications.share_success'));
|
||||
@@ -994,8 +1009,14 @@ const contextMenus = {
|
||||
app.notificationShareUrl = null;
|
||||
},
|
||||
|
||||
/** @type {String | null} */
|
||||
_selectedPlaylistId: null,
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
* @returns
|
||||
*/
|
||||
async showPlaylistDialog(file) {
|
||||
const dialog = document.getElementById('playlist-dialog');
|
||||
const container = document.getElementById('playlist-select-container');
|
||||
@@ -1031,6 +1052,7 @@ const contextMenus = {
|
||||
const resp = await fetch('/api/playlists', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Failed to load playlists');
|
||||
|
||||
/** @type {Playlist[]} */
|
||||
const playlists = await resp.json();
|
||||
this._renderPlaylistSelect(container, playlists);
|
||||
} catch (err) {
|
||||
@@ -1039,6 +1061,12 @@ const contextMenus = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} container
|
||||
* @param {Playlist[]} playlists
|
||||
* @returns
|
||||
*/
|
||||
_renderPlaylistSelect(container, playlists) {
|
||||
container.innerHTML = '';
|
||||
|
||||
@@ -1124,6 +1152,12 @@ const contextMenus = {
|
||||
this._selectedPlaylistId = null;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns
|
||||
*/
|
||||
//FIXME: move to common library
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
@@ -11,10 +11,18 @@ import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { notifications } from '../../core/notifications.js';
|
||||
|
||||
/** @import {TrashItem} from '../../core/types.js' */
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchResult
|
||||
* @property {number} success number of files|folders sucessfully updated
|
||||
* @property {number} errors number of files|folders in error
|
||||
* /
|
||||
|
||||
/**
|
||||
* Get authorization headers for API requests.
|
||||
* Tokens are now in HttpOnly cookies — no explicit Authorization header needed.
|
||||
* @returns {Object} Headers object
|
||||
* @returns {Record<String, String>} Headers object
|
||||
*/
|
||||
function getAuthHeaders() {
|
||||
return { ...getCsrfHeaders() };
|
||||
@@ -25,15 +33,26 @@ const fileOps = {
|
||||
// ========================================================================
|
||||
// Upload progress — notification bell integration
|
||||
// ========================================================================
|
||||
/** @type {string | null} */
|
||||
_currentBatchId: null,
|
||||
|
||||
/** @type {boolean} */
|
||||
_isUploading: false, // Guard against concurrent upload calls
|
||||
|
||||
/** Start a new upload batch in the notification bell */
|
||||
/**
|
||||
* Start a new upload batch in the notification bell
|
||||
* @param {number} totalFiles
|
||||
* @param {string} [folderName]
|
||||
*/
|
||||
_initUploadToast(totalFiles, folderName) {
|
||||
this._currentBatchId = notifications.addUploadBatch(totalFiles, folderName);
|
||||
},
|
||||
|
||||
/** Finalise the batch in the notification bell */
|
||||
/**
|
||||
* Finalise the batch in the notification bell
|
||||
* @param {number} successCount
|
||||
* @param {number} totalFiles
|
||||
* */
|
||||
_finishUploadToast(successCount, totalFiles) {
|
||||
if (this._currentBatchId) {
|
||||
notifications.finishBatch(this._currentBatchId, successCount, totalFiles);
|
||||
@@ -44,6 +63,8 @@ const fileOps = {
|
||||
* Some drag-and-drop sources can inject directory placeholders into
|
||||
* DataTransfer.files. Browsers fail those with net::ERR_ACCESS_DENIED
|
||||
* when trying to send them as normal files.
|
||||
* @param {File} file
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
_canReadFileBlob(file) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -58,10 +79,24 @@ const fileOps = {
|
||||
});
|
||||
},
|
||||
|
||||
// FIXME: prefer exceptions for errors
|
||||
/**
|
||||
* @typedef {Object} UploadAnswer
|
||||
* @property {boolean} ok
|
||||
* @property {any} [data]
|
||||
* @property {string} [errorMsg]
|
||||
* @property {boolean} [isQuotaError]
|
||||
* @property {boolean} [isTimeout]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Upload a single file via XMLHttpRequest with progress events.
|
||||
* Progress is reported to the notification bell via batchId + fileName.
|
||||
* Returns a promise that resolves with { ok, data?, errorMsg?, isQuotaError? }.
|
||||
* @param {FormData} formData
|
||||
* @param {string} batchId
|
||||
* @param {string} fileName
|
||||
* @param {number} [timeoutMs=120000]
|
||||
*/
|
||||
_uploadFileXHR(formData, batchId, fileName, timeoutMs = 120000) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -76,9 +111,16 @@ const fileOps = {
|
||||
let lastProgressPctSent = -1;
|
||||
|
||||
let isSettled = false;
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let stallTimer = null;
|
||||
/** @type {ReturnType<typeof setTimeout>} */
|
||||
let hardTimer = null;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} pct
|
||||
* @param {'uploading' | 'done' | 'error'} status
|
||||
*/
|
||||
const safeUpdateFile = (pct, status) => {
|
||||
if (!notif || !batchId) return;
|
||||
try {
|
||||
@@ -88,6 +130,11 @@ const fileOps = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {UploadAnswer} result
|
||||
* @returns
|
||||
*/
|
||||
const finalize = (result) => {
|
||||
if (isSettled) return;
|
||||
isSettled = true;
|
||||
@@ -219,6 +266,12 @@ const fileOps = {
|
||||
* Used by folder uploads to avoid browser XHR edge-cases with dragged entries.
|
||||
* Returns { ok, data?, errorMsg?, isQuotaError?, isTimeout? }.
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param {*} formData
|
||||
* @param {*} timeoutMs
|
||||
* @returns {Promise<UploadAnswer>}
|
||||
*/
|
||||
async _uploadFileFetch(formData, timeoutMs = 60000) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -254,11 +307,13 @@ const fileOps = {
|
||||
const isQuotaError = (body && typeof body === 'object' && body.error_type === 'QuotaExceeded') || response.status === 507;
|
||||
return { ok: false, errorMsg, isQuotaError };
|
||||
} catch (e) {
|
||||
const isTimeout = e?.name === 'AbortError';
|
||||
const isTimeout = /** @type {Error} */ (e)?.name === 'AbortError';
|
||||
return {
|
||||
ok: false,
|
||||
isTimeout,
|
||||
errorMsg: isTimeout ? `Timeout after ${Math.round(timeoutMs / 1000)}s` : `Fetch upload failed: ${e?.message || 'network error'}`
|
||||
errorMsg: isTimeout
|
||||
? `Timeout after ${Math.round(timeoutMs / 1000)}s`
|
||||
: `Fetch upload failed: ${/** @type {Error} */ (e)?.message || 'network error'}`
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
@@ -458,6 +513,7 @@ const fileOps = {
|
||||
|
||||
try {
|
||||
// Filter unreadable entries
|
||||
/** @type {Array<{file: File, relativePath: string}>} */
|
||||
const validEntries = [];
|
||||
for (const e of rawEntries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
@@ -560,12 +616,18 @@ const fileOps = {
|
||||
const TIMEOUT_MIN_MS = 10000; // floor for tiny files
|
||||
const TIMEOUT_MS_ZERO = 3000; // 3s for 0-byte files
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} idx
|
||||
* @returns
|
||||
*/
|
||||
const uploadOneFile = async (idx) => {
|
||||
if (quotaStop) return;
|
||||
const entry = validEntries[idx];
|
||||
const file = entry.file;
|
||||
const rel = entry.relativePath || file.name;
|
||||
|
||||
/** @type {UploadAnswer} */
|
||||
let result = { ok: false, errorMsg: 'Unknown client error' };
|
||||
try {
|
||||
const parts = rel.split('/');
|
||||
@@ -577,6 +639,7 @@ const fileOps = {
|
||||
// but block on open(). Pre-read only 0-byte files into
|
||||
// memory; files with size>0 are always regular files and
|
||||
// go straight to FormData (zero extra memory copy).
|
||||
/** @type {Blob} */
|
||||
let uploadFile = file; // default: use original File
|
||||
if (file.size === 0) {
|
||||
try {
|
||||
@@ -616,7 +679,7 @@ const fileOps = {
|
||||
} catch (e) {
|
||||
result = {
|
||||
ok: false,
|
||||
errorMsg: `Client exception: ${e?.message || 'unknown'}`
|
||||
errorMsg: `Client exception: ${/** @type {Error} */ (e)?.message || 'unknown'}`
|
||||
};
|
||||
console.error(`[UPLOAD EXCEPTION] #${idx} ${rel}:`, e);
|
||||
}
|
||||
@@ -823,12 +886,6 @@ const fileOps = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchResult
|
||||
* @property {number} success number of files|folders sucessfully updated
|
||||
* @property {number} errors number of files|folders in error
|
||||
* /
|
||||
|
||||
/**
|
||||
* Move files & folders
|
||||
* @param {string[]} fileIds - File IDs
|
||||
@@ -941,18 +998,12 @@ const fileOps = {
|
||||
return res.ok;
|
||||
},
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchCopyReturn
|
||||
* @property {number} success
|
||||
* @property {number} errors
|
||||
*/
|
||||
|
||||
/**
|
||||
* Copy files & folders
|
||||
* @param {string[]} fileIds - File IDs
|
||||
* @param {string[]} folderIds - Folder IDs
|
||||
* @param {string} targetFolderId - Target folder ID
|
||||
* @returns {Promise<BatchCopyReturn>} - Success status
|
||||
* @returns {Promise<BatchResult>} - Success status
|
||||
*/
|
||||
async batchCopy(fileIds, folderIds, targetFolderId) {
|
||||
// FIXME ensure not moving a folder into itself
|
||||
@@ -1010,7 +1061,6 @@ const fileOps = {
|
||||
* Rename a file
|
||||
* @param {string} fileId - File ID
|
||||
* @param {string} newName - New file name
|
||||
* @returns {Promise<string|null>} - null on success, error message string on failure
|
||||
*/
|
||||
async renameFile(fileId, newName) {
|
||||
try {
|
||||
@@ -1052,13 +1102,6 @@ const fileOps = {
|
||||
* Rename a folder
|
||||
* @param {string} folderId - Folder ID
|
||||
* @param {string} newName - New folder name
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
/**
|
||||
* Rename a folder
|
||||
* @param {string} folderId - Folder ID
|
||||
* @param {string} newName - New folder name
|
||||
* @returns {Promise<string|null>} - null on success, error message string on failure
|
||||
*/
|
||||
async renameFolder(folderId, newName) {
|
||||
try {
|
||||
@@ -1170,7 +1213,7 @@ const fileOps = {
|
||||
// If we're inside the folder we just deleted, go back up
|
||||
if (app.currentPath === folderId) {
|
||||
app.currentPath = '';
|
||||
ui.updateBreadcrumb('');
|
||||
ui.updateBreadcrumb();
|
||||
}
|
||||
loadFiles();
|
||||
ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`);
|
||||
@@ -1186,7 +1229,7 @@ const fileOps = {
|
||||
// If we're inside the folder we just deleted, go back up
|
||||
if (app.currentPath === folderId) {
|
||||
app.currentPath = '';
|
||||
ui.updateBreadcrumb('');
|
||||
ui.updateBreadcrumb();
|
||||
}
|
||||
loadFiles();
|
||||
ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`);
|
||||
@@ -1205,7 +1248,7 @@ const fileOps = {
|
||||
|
||||
/**
|
||||
* Get trash items
|
||||
* @returns {Promise<Array>} - List of trash items
|
||||
* @returns {Promise<Array<TrashItem>>} - List of trash items
|
||||
*/
|
||||
async getTrashItems() {
|
||||
try {
|
||||
@@ -1214,7 +1257,7 @@ const fileOps = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
return /** @type {TrashItem[]} */ (await response.json());
|
||||
} else {
|
||||
console.error('Error fetching trash items:', response.statusText);
|
||||
return [];
|
||||
|
||||
@@ -8,6 +8,8 @@ import { app } from '../../app/state.js';
|
||||
import { isTextViewable } from '../../core/formatters.js';
|
||||
import { wopiEditor } from './wopiEditor.js';
|
||||
|
||||
/** @import {FileItem} from '../../core/types.js' */
|
||||
|
||||
class InlineViewer {
|
||||
constructor() {
|
||||
this.setupViewer();
|
||||
@@ -93,6 +95,11 @@ class InlineViewer {
|
||||
console.log('Inline viewer initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
* @returns
|
||||
*/
|
||||
async openFile(file) {
|
||||
console.log('Opening file:', file);
|
||||
|
||||
@@ -115,7 +122,7 @@ class InlineViewer {
|
||||
|
||||
// Get container
|
||||
const modal = document.getElementById('inline-viewer-modal');
|
||||
const container = modal.querySelector('.inline-viewer-container');
|
||||
const container = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-container'));
|
||||
const title = modal.querySelector('.inline-viewer-title');
|
||||
|
||||
// Clear container
|
||||
@@ -125,12 +132,12 @@ class InlineViewer {
|
||||
title.textContent = file.name;
|
||||
|
||||
// Set controls visibility
|
||||
const controls = modal.querySelector('.inline-viewer-controls');
|
||||
const controls = /** @type {HTMLDivElement} */ (modal.querySelector('.inline-viewer-controls'));
|
||||
|
||||
// Show viewer based on file type
|
||||
if (isImage) {
|
||||
// Show zoom controls
|
||||
controls.style.display = 'flex';
|
||||
controls.classList.remove('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -142,7 +149,7 @@ class InlineViewer {
|
||||
this.createBlobUrlViewer(file, 'image', container, loader);
|
||||
} else if (file.mime_type && file.mime_type === 'application/pdf') {
|
||||
// Hide zoom controls for PDFs
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -152,9 +159,9 @@ class InlineViewer {
|
||||
|
||||
// Create PDF viewer using object tag with blob URL
|
||||
this.createBlobUrlViewer(file, 'pdf', container, loader);
|
||||
} else if (file.mime_type && this.isTextViewable(file.mime_type)) {
|
||||
} else if (file.mime_type && isTextViewable(file.mime_type)) {
|
||||
// Hide zoom controls for text files
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -166,7 +173,7 @@ class InlineViewer {
|
||||
this.createTextViewer(file, container, loader);
|
||||
} else if (file.mime_type?.startsWith('audio/')) {
|
||||
// Hide zoom controls for audio
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -178,7 +185,7 @@ class InlineViewer {
|
||||
this.createMediaViewer(file, 'audio', container, loader);
|
||||
} else if (file.mime_type?.startsWith('video/')) {
|
||||
// Hide zoom controls for video
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show loading indicator
|
||||
const loader = document.createElement('div');
|
||||
@@ -190,7 +197,7 @@ class InlineViewer {
|
||||
this.createMediaViewer(file, 'video', container, loader);
|
||||
} else {
|
||||
// Hide zoom controls for unsupported files
|
||||
controls.style.display = 'none';
|
||||
controls.classList.add('hidden');
|
||||
|
||||
// Show unsupported file message
|
||||
const message = document.createElement('div');
|
||||
@@ -209,12 +216,13 @@ class InlineViewer {
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
// Check if a MIME type is text-viewable
|
||||
isTextViewable(mimeType) {
|
||||
return isTextViewable(mimeType);
|
||||
}
|
||||
|
||||
// Creates a text viewer using authenticated fetch
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
* @param {HTMLDivElement} container
|
||||
* @param {*} loader
|
||||
*/
|
||||
async createTextViewer(file, container, loader) {
|
||||
try {
|
||||
console.log('Creating text viewer for:', file.name);
|
||||
@@ -253,14 +261,20 @@ class InlineViewer {
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a viewer using a Blob URL to avoid content-disposition header
|
||||
async createBlobUrlViewer(file, type, container, loader) {
|
||||
/**
|
||||
* Creates a viewer using a Blob URL to avoid content-disposition header
|
||||
* @param {FileItem} file
|
||||
* @param {string} mediaType
|
||||
* @param {HTMLDivElement} container
|
||||
* @param {HTMLDivElement} loader
|
||||
*/
|
||||
async createBlobUrlViewer(file, mediaType, container, loader) {
|
||||
try {
|
||||
console.log('Creating blob URL viewer for:', file.name, 'type:', type);
|
||||
console.log('Creating blob URL viewer for:', file.name, 'type:', mediaType);
|
||||
|
||||
// Update loader to show progress bar for large files
|
||||
let progressBar = null;
|
||||
let progressText = null;
|
||||
let progressBar = /** @type {HTMLElement|null} */ (null);
|
||||
let progressText = /** @type {HTMLElement|null} */ (null);
|
||||
if (loader && file.size > 10 * 1024 * 1024) {
|
||||
// Show progress for files > 10MB
|
||||
loader.innerHTML = `
|
||||
@@ -272,8 +286,8 @@ class InlineViewer {
|
||||
<div class="inline-viewer-progress-text">0%</div>
|
||||
</div>
|
||||
`;
|
||||
progressBar = loader.querySelector('.inline-viewer-progress-fill');
|
||||
progressText = loader.querySelector('.inline-viewer-progress-text');
|
||||
progressBar = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-fill'));
|
||||
progressText = /** @type {HTMLElement|null} */ (loader.querySelector('.inline-viewer-progress-text'));
|
||||
}
|
||||
|
||||
// Use XMLHttpRequest instead of fetch to get better control over the response
|
||||
@@ -322,7 +336,7 @@ class InlineViewer {
|
||||
loader.parentNode.removeChild(loader);
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
if (mediaType === 'image') {
|
||||
console.log('Creating image viewer');
|
||||
// Create image element
|
||||
const img = document.createElement('img');
|
||||
@@ -332,10 +346,10 @@ class InlineViewer {
|
||||
container.appendChild(img);
|
||||
|
||||
// Add loading indicator until image loads
|
||||
img.style.opacity = 0;
|
||||
img.style.opacity = String(0);
|
||||
img.onload = () => {
|
||||
console.log('Image loaded successfully');
|
||||
img.style.opacity = 1;
|
||||
img.style.opacity = String(1);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
@@ -343,7 +357,7 @@ class InlineViewer {
|
||||
container.removeChild(img);
|
||||
this.showErrorMessage(container);
|
||||
};
|
||||
} else if (type === 'pdf') {
|
||||
} else if (mediaType === 'pdf') {
|
||||
console.log('Creating PDF viewer');
|
||||
|
||||
// Create iframe for PDF (more reliable than object tag)
|
||||
@@ -382,7 +396,13 @@ class InlineViewer {
|
||||
}
|
||||
}
|
||||
|
||||
// Creates an audio or video player using blob URL (authenticated fetch)
|
||||
/**
|
||||
* Creates an audio or video player using blob URL (authenticated fetch)
|
||||
* @param {FileItem} file
|
||||
* @param {string} mediaType
|
||||
* @param {HTMLDivElement} container
|
||||
* @param {HTMLDivElement} loader
|
||||
*/
|
||||
async createMediaViewer(file, mediaType, container, loader) {
|
||||
try {
|
||||
console.log(`Creating ${mediaType} player for:`, file.name);
|
||||
@@ -485,7 +505,10 @@ class InlineViewer {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to show error message
|
||||
/**
|
||||
* Helper to show error message
|
||||
* @param {HTMLDivElement} container
|
||||
*/
|
||||
showErrorMessage(container) {
|
||||
// Show error message
|
||||
const message = document.createElement('div');
|
||||
@@ -505,7 +528,7 @@ class InlineViewer {
|
||||
const modal = document.getElementById('inline-viewer-modal');
|
||||
|
||||
// stops audio/video before closing viewver
|
||||
const media = modal.querySelector('audio, video');
|
||||
const media = /** @type {HTMLMediaElement} */ (modal.querySelector('audio, video'));
|
||||
if (media && !media.paused) media.pause();
|
||||
|
||||
// Hide modal
|
||||
@@ -525,6 +548,10 @@ class InlineViewer {
|
||||
this.currentFile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem} file
|
||||
*/
|
||||
downloadFile(file) {
|
||||
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
|
||||
.then((res) => {
|
||||
@@ -544,9 +571,14 @@ class InlineViewer {
|
||||
.catch((err) => console.error('Download error:', err));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} factor
|
||||
* @returns
|
||||
*/
|
||||
zoomImage(factor) {
|
||||
const container = document.querySelector('.inline-viewer-container');
|
||||
const img = container.querySelector('.inline-viewer-image');
|
||||
const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
|
||||
|
||||
if (!img) return;
|
||||
|
||||
@@ -560,7 +592,7 @@ class InlineViewer {
|
||||
scale = Math.max(0.1, Math.min(5.0, scale));
|
||||
|
||||
// Save scale
|
||||
img.dataset.scale = scale;
|
||||
img.dataset.scale = String(scale);
|
||||
|
||||
// Apply scale
|
||||
img.style.transform = `scale(${scale})`;
|
||||
@@ -568,12 +600,12 @@ class InlineViewer {
|
||||
|
||||
resetZoom() {
|
||||
const container = document.querySelector('.inline-viewer-container');
|
||||
const img = container.querySelector('.inline-viewer-image');
|
||||
const img = /** @type {HTMLDivElement} */ (container.querySelector('.inline-viewer-image'));
|
||||
|
||||
if (!img) return;
|
||||
|
||||
// Reset scale
|
||||
img.dataset.scale = 1.0;
|
||||
img.dataset.scale = String(1);
|
||||
img.style.transform = 'scale(1.0)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
// TODO: rename into selection-bar ?
|
||||
// TODO: merge with photo part
|
||||
|
||||
// @ts-check
|
||||
|
||||
import { loadFiles } from '../../app/filesView.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||
@@ -19,8 +17,14 @@ import { favorites } from '../library/favorites.js';
|
||||
import { contextMenus } from './contextMenus.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
|
||||
/**
|
||||
* @import {ItemTypeEnum, LightItem} from '../../core/types.js'
|
||||
* @import {BatchResult} from './fileOperations.js'
|
||||
*/
|
||||
|
||||
const multiSelect = {
|
||||
/** Currently selected items: Map<id, { id, name, type, parentId }> */
|
||||
/** @type {Map<String, LightItem>} items: Map<id, { id, name, type, parentId }> */
|
||||
|
||||
_selected: new Map(),
|
||||
|
||||
/** Last clicked index for Shift-range selection */
|
||||
@@ -49,6 +53,12 @@ const multiSelect = {
|
||||
|
||||
// ── Helpers for i18n ────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {any} vars
|
||||
* @returns
|
||||
*/
|
||||
_t(key, vars) {
|
||||
const val = i18n.t(key, vars);
|
||||
return val !== key ? val : null;
|
||||
@@ -56,6 +66,14 @@ const multiSelect = {
|
||||
|
||||
// ── Selection state management ──────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @param {ItemTypeEnum} type
|
||||
* @param {string} parentId
|
||||
* @returns
|
||||
*/
|
||||
toggle(id, name, type, parentId) {
|
||||
if (this._selected.has(id)) {
|
||||
this._selected.delete(id);
|
||||
@@ -65,10 +83,22 @@ const multiSelect = {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @param {ItemTypeEnum} type
|
||||
* @param {string} parentId
|
||||
* @returns
|
||||
*/
|
||||
select(id, name, type, parentId) {
|
||||
this._selected.set(id, { id, name, type, parentId });
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
*/
|
||||
deselect(id) {
|
||||
this._selected.delete(id);
|
||||
},
|
||||
@@ -80,7 +110,7 @@ const multiSelect = {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
document.querySelectorAll('.item-checkbox').forEach((cb) => {
|
||||
cb.checked = false;
|
||||
/** @type {HTMLInputElement} */ (cb).checked = false;
|
||||
});
|
||||
this._syncUI();
|
||||
},
|
||||
@@ -111,11 +141,13 @@ const multiSelect = {
|
||||
* @return {ItemSelection}
|
||||
*/
|
||||
getSelection(targtFolderId) {
|
||||
/** @type {Array<string>} */
|
||||
const fileIds = [];
|
||||
/** @type {Array<string>} */
|
||||
const folderIds = [];
|
||||
|
||||
// TODO optimize & check if _selected is a better use
|
||||
document.querySelectorAll(`div.file-item.selected`).forEach((item) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
|
||||
if (item.dataset.fileId) {
|
||||
fileIds.push(item.dataset.fileId);
|
||||
} else {
|
||||
@@ -152,6 +184,10 @@ const multiSelect = {
|
||||
|
||||
// ── DOM helpers ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLDivElement} el
|
||||
*/
|
||||
_selectElement(el) {
|
||||
const info = this._extractInfo(el);
|
||||
if (info) {
|
||||
@@ -160,18 +196,32 @@ const multiSelect = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} containerId
|
||||
* @param {string} selector
|
||||
* @returns {void}
|
||||
*/
|
||||
_selectAllInContainer(containerId, selector) {
|
||||
const container = document.getElementById(containerId);
|
||||
const container = /** @type {HTMLDivElement} */ (document.getElementById(containerId));
|
||||
if (!container) return;
|
||||
container.querySelectorAll(selector).forEach((el) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (container.querySelectorAll(selector)).forEach((el) => {
|
||||
this._selectElement(el);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {HTMLDivElement[]}
|
||||
*/
|
||||
_getAllVisibleItems() {
|
||||
return [...document.querySelectorAll('.file-item')];
|
||||
return /** @type {HTMLDivElement[]} */ ([...document.querySelectorAll('.file-item')]);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {HTMLDivElement} el
|
||||
* @returns {LightItem}
|
||||
*/
|
||||
_extractInfo(el) {
|
||||
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
|
||||
return {
|
||||
@@ -194,6 +244,10 @@ const multiSelect = {
|
||||
|
||||
// ── Click handler (shared by grid + list) ───────────────
|
||||
|
||||
/**
|
||||
* @param {HTMLDivElement} el
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleToggleItem(el, event) {
|
||||
const items = this._getAllVisibleItems();
|
||||
const index = items.indexOf(el);
|
||||
@@ -210,7 +264,7 @@ const multiSelect = {
|
||||
const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`;
|
||||
document.querySelectorAll(sel).forEach((e) => {
|
||||
e.classList.add('selected');
|
||||
const checkbox = e.querySelector('input[type="checkbox"]');
|
||||
const checkbox = /** @type {HTMLInputElement} */ (e.querySelector('input[type="checkbox"]'));
|
||||
if (checkbox) checkbox.checked = true;
|
||||
});
|
||||
}
|
||||
@@ -218,7 +272,7 @@ const multiSelect = {
|
||||
} else {
|
||||
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
|
||||
el.classList.toggle('selected', nowSelected);
|
||||
const checkbox = el.querySelector('input[type="checkbox"]');
|
||||
const checkbox = /** @type {HTMLInputElement} */ (el.querySelector('input[type="checkbox"]'));
|
||||
if (checkbox) checkbox.checked = nowSelected;
|
||||
}
|
||||
this._lastClickedIndex = index;
|
||||
@@ -271,13 +325,13 @@ const multiSelect = {
|
||||
|
||||
_syncItemCheckboxes() {
|
||||
document.querySelectorAll('.file-item').forEach((el) => {
|
||||
const cb = el.querySelector('.item-checkbox');
|
||||
const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox'));
|
||||
if (cb) cb.checked = el.classList.contains('selected');
|
||||
});
|
||||
},
|
||||
|
||||
_syncSelectAllCheckbox() {
|
||||
const cb = document.getElementById('select-all-checkbox');
|
||||
const cb = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
|
||||
if (!cb) return;
|
||||
const all = this._getAllVisibleItems();
|
||||
if (all.length === 0) {
|
||||
@@ -454,9 +508,10 @@ const multiSelect = {
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
||||
const target = /** @type {Element} */ (e.target);
|
||||
if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
|
||||
|
||||
const selectAllCheckbox = document.getElementById('select-all-checkbox');
|
||||
const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox'));
|
||||
// ctrl+a cmd+a
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
if (selectAllCheckbox) selectAllCheckbox.checked = true;
|
||||
|
||||
@@ -12,6 +12,10 @@ import { app } from '../../app/state.js';
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
|
||||
/**
|
||||
* @import {SearchCriteria, SearchResults} from '../../core/types.js'}
|
||||
*/
|
||||
|
||||
const search = {
|
||||
/**
|
||||
* Perform a search using query parameters.
|
||||
@@ -19,25 +23,29 @@ const search = {
|
||||
* relevance_score, icon_class, category, size_formatted, etc.
|
||||
*
|
||||
* @param {string} query - Search query
|
||||
* @param {Object} options - Additional search options
|
||||
* @returns {Promise<Object>} - Enriched search results from backend
|
||||
* @param {SearchCriteria} [options] - Additional search options
|
||||
* @returns {Promise<SearchResults>} - Enriched search results from backend
|
||||
*/
|
||||
async searchFiles(query, options = {}) {
|
||||
async searchFiles(query, options) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
|
||||
if (options.folder_id) params.append('folder_id', options.folder_id);
|
||||
if (options.recursive !== undefined) params.append('recursive', options.recursive);
|
||||
if (options.file_types) params.append('type', options.file_types);
|
||||
if (options.min_size) params.append('min_size', options.min_size);
|
||||
if (options.max_size) params.append('max_size', options.max_size);
|
||||
if (options.created_after) params.append('created_after', options.created_after);
|
||||
if (options.created_before) params.append('created_before', options.created_before);
|
||||
if (options.modified_after) params.append('modified_after', options.modified_after);
|
||||
if (options.modified_before) params.append('modified_before', options.modified_before);
|
||||
if (options.limit) params.append('limit', options.limit);
|
||||
if (options.offset) params.append('offset', options.offset);
|
||||
if (options.recursive !== undefined) params.append('recursive', String(options.recursive));
|
||||
if (options.file_types) {
|
||||
options.file_types.forEach((file_type) => {
|
||||
params.append('type', file_type);
|
||||
});
|
||||
}
|
||||
if (options.min_size) params.append('min_size', String(options.min_size));
|
||||
if (options.max_size) params.append('max_size', String(options.max_size));
|
||||
if (options.created_after) params.append('created_after', String(options.created_after));
|
||||
if (options.created_before) params.append('created_before', String(options.created_before));
|
||||
if (options.modified_after) params.append('modified_after', String(options.modified_after));
|
||||
if (options.modified_before) params.append('modified_before', String(options.modified_before));
|
||||
if (options.limit) params.append('limit', String(options.limit));
|
||||
if (options.offset) params.append('offset', String(options.offset));
|
||||
if (options.sort_by) params.append('sort_by', options.sort_by);
|
||||
|
||||
const url = `/api/search?${params.toString()}`;
|
||||
@@ -46,6 +54,7 @@ const search = {
|
||||
const response = await fetch(url, { headers: getAuthHeaders() });
|
||||
|
||||
if (response.ok) {
|
||||
/** @type {SearchResults} */
|
||||
return await response.json();
|
||||
} else {
|
||||
let errorText = '';
|
||||
@@ -66,7 +75,10 @@ const search = {
|
||||
folders: [],
|
||||
total_count: 0,
|
||||
query_time_ms: 0,
|
||||
sort_by: 'relevance'
|
||||
sort_by: 'relevance',
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
has_more: false
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -76,15 +88,15 @@ const search = {
|
||||
* Returns lightweight name suggestions without full search overhead.
|
||||
*
|
||||
* @param {string} query - Prefix to search for
|
||||
* @param {Object} options - { folder_id, limit }
|
||||
* @param {SearchCriteria} [options] - { folder_id, limit }
|
||||
* @returns {Promise<Object>} - { suggestions: [...], query_time_ms }
|
||||
*/
|
||||
async getSuggestions(query, options = {}) {
|
||||
async getSuggestions(query, options) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
if (options.folder_id) params.append('folder_id', options.folder_id);
|
||||
if (options.limit) params.append('limit', options.limit);
|
||||
if (options.limit) params.append('limit', String(options.limit));
|
||||
|
||||
const url = `/api/search/suggest?${params.toString()}`;
|
||||
const response = await fetch(url, { headers: getAuthHeaders() });
|
||||
@@ -111,7 +123,7 @@ const search = {
|
||||
* - query_time_ms: Server-side query execution time
|
||||
* - sort_by: Active sort order
|
||||
*
|
||||
* @param {Object} results - Enriched search results from backend
|
||||
* @param {SearchResults} results - Enriched search results from backend
|
||||
*/
|
||||
displaySearchResults(results) {
|
||||
ui.resetFilesList(); // ensure also list visible & error hidden
|
||||
|
||||
@@ -19,6 +19,7 @@ class WopiEditor {
|
||||
/**
|
||||
* Check if a file can be opened in a WOPI editor by extension.
|
||||
* Fetches supported extensions from the server (cached after first call).
|
||||
* @param {string} filename
|
||||
*/
|
||||
async canEdit(filename) {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
@@ -28,6 +29,9 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Open file in a modal overlay (default mode).
|
||||
* @param {string} fileId
|
||||
* @param {string} fileName
|
||||
* @param {string} [action]
|
||||
*/
|
||||
async openInModal(fileId, fileName, action) {
|
||||
action = action || 'edit';
|
||||
@@ -38,6 +42,9 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Open file in a new browser tab.
|
||||
* @param {string} fileId
|
||||
* @param {string} fileName
|
||||
* @param {string} [action]
|
||||
*/
|
||||
async openInTab(fileId, fileName, action) {
|
||||
action = action || 'edit';
|
||||
@@ -53,6 +60,8 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Fetch editor URL and WOPI token from the backend.
|
||||
* @param {string} fileId
|
||||
* @param {string} action
|
||||
*/
|
||||
async _getEditorUrl(fileId, action) {
|
||||
const response = await fetch(`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`, {
|
||||
@@ -68,6 +77,9 @@ class WopiEditor {
|
||||
/**
|
||||
* Some WOPI file types, such as PDFs, are view-only.
|
||||
* If an edit request returns 422, retry once in view mode.
|
||||
* @param {string} fileId
|
||||
* @param {string} fileName
|
||||
* @param {string} action
|
||||
*/
|
||||
async _getEditorUrlWithFallback(fileId, fileName, action) {
|
||||
try {
|
||||
@@ -81,6 +93,11 @@ class WopiEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileName
|
||||
* @param {string} action
|
||||
* @param {any} error
|
||||
*/
|
||||
_shouldRetryInViewMode(fileName, action, error) {
|
||||
if (action !== 'edit' || !error || !error.message) {
|
||||
return false;
|
||||
@@ -92,6 +109,8 @@ class WopiEditor {
|
||||
|
||||
/**
|
||||
* Show the editor in a full-screen modal with iframe.
|
||||
* @param {Record<string, any>} editorData
|
||||
* @param {string} fileName
|
||||
*/
|
||||
_showModal(editorData, fileName) {
|
||||
this.closeEditor();
|
||||
@@ -160,13 +179,13 @@ class WopiEditor {
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// ESC key handler
|
||||
this._escHandler = function (e) {
|
||||
this._escHandler = (/** @type {KeyboardEvent} */ e) => {
|
||||
if (e.key === 'Escape') this.closeEditor();
|
||||
}.bind(this);
|
||||
};
|
||||
document.addEventListener('keydown', this._escHandler);
|
||||
|
||||
// Fix 7: Listen for postMessage from the editor iframe
|
||||
this._messageHandler = function (e) {
|
||||
this._messageHandler = (/** @type {MessageEvent} */ e) => {
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
@@ -183,7 +202,7 @@ class WopiEditor {
|
||||
if (sp) sp.remove();
|
||||
}
|
||||
}
|
||||
}.bind(this);
|
||||
};
|
||||
window.addEventListener('message', this._messageHandler);
|
||||
|
||||
form.submit();
|
||||
|
||||
@@ -12,8 +12,10 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { multiSelect } from '../files/multiSelect.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
/** @import {FavoriteItem, FileItem, FolderItem} from '../../core/types.js' */
|
||||
|
||||
const favorites = {
|
||||
/** @type {Map<string, object>} key = "file:<id>" | "folder:<id>" */
|
||||
/** @type {Map<string, FavoriteItem>} key = "file:<id>" | "folder:<id>" */
|
||||
_cache: new Map(),
|
||||
|
||||
/** Whether the initial fetch from the server has completed */
|
||||
@@ -25,6 +27,10 @@ const favorites = {
|
||||
return { ...getCsrfHeaders() };
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {string} type
|
||||
*/
|
||||
_cacheKey(id, type) {
|
||||
return `${type}:${id}`;
|
||||
},
|
||||
@@ -33,6 +39,7 @@ const favorites = {
|
||||
* Replace the entire in-memory cache from an array of FavoriteItemDto
|
||||
* objects (as returned by the batch endpoint). Avoids an extra
|
||||
* GET /api/favorites round-trip.
|
||||
* @param {any[]} items
|
||||
*/
|
||||
_replaceCacheFromResponse(items) {
|
||||
this._cache.clear();
|
||||
@@ -68,6 +75,7 @@ const favorites = {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FavoriteItem[]} */
|
||||
const items = await response.json();
|
||||
this._cache.clear();
|
||||
for (const item of items) {
|
||||
@@ -85,6 +93,8 @@ const favorites = {
|
||||
|
||||
/**
|
||||
* Synchronous check used by ui.js to paint star icons.
|
||||
* @param {string} id
|
||||
* @param {string} type
|
||||
*/
|
||||
isFavorite(id, type) {
|
||||
return this._cache.has(this._cacheKey(id, type));
|
||||
@@ -92,6 +102,10 @@ const favorites = {
|
||||
|
||||
/**
|
||||
* Add an item to favourites (server-first).
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @param {string} type
|
||||
* @param {string} _parentId
|
||||
*/
|
||||
async addToFavorites(id, name, type, _parentId) {
|
||||
try {
|
||||
@@ -121,6 +135,8 @@ const favorites = {
|
||||
|
||||
/**
|
||||
* Remove an item from favourites (server-first).
|
||||
* @param {string} id
|
||||
* @param {string} type
|
||||
*/
|
||||
async removeFromFavorites(id, type) {
|
||||
try {
|
||||
@@ -178,31 +194,51 @@ const favorites = {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
|
||||
for (const item of this._cache.values()) {
|
||||
// TODO: cast objects, but for that need to review user_id vs owner_id...
|
||||
if (item.item_type === 'folder') {
|
||||
folders.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.modified_at || item.created_at,
|
||||
path: item.item_path || ''
|
||||
});
|
||||
folders.push(
|
||||
// FIXME: better to grab the real values
|
||||
/** @type {FolderItem} */ {
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.modified_at || item.created_at,
|
||||
path: item.item_path || '',
|
||||
category: 'folder',
|
||||
created_at: item.created_at,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
owner_id: item.user_id,
|
||||
is_root: false
|
||||
}
|
||||
);
|
||||
} else {
|
||||
files.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.modified_at || item.created_at,
|
||||
path: item.item_path || ''
|
||||
});
|
||||
files.push(
|
||||
// FIXME: better to grab the real values
|
||||
/** @type {FileItem} */ {
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.modified_at || item.created_at,
|
||||
path: item.item_path || '',
|
||||
owner_id: item.user_id,
|
||||
created_at: item.created_at,
|
||||
sort_date: item.created_at
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
if (folders.length) ui.renderFolders(folders);
|
||||
|
||||
@@ -6,17 +6,27 @@ import { oxiIcon } from '../../core/icons.js';
|
||||
import { Modal } from '../../core/modal.js';
|
||||
import { notifications } from '../../core/notifications.js';
|
||||
|
||||
/** @import {FileItem, Musicshare, Playlist, PlaylistItem} from '../../core/types.js' */
|
||||
|
||||
/**
|
||||
* OxiCloud - Music Library View
|
||||
* Playlist management with track listings and audio player
|
||||
*/
|
||||
|
||||
const musicView = {
|
||||
/** @type {Playlist[]} */
|
||||
playlists: [],
|
||||
|
||||
/** @type {Playlist | null} */
|
||||
currentPlaylist: null,
|
||||
|
||||
/** @type {PlaylistItem[]} */
|
||||
currentTracks: [],
|
||||
loading: false,
|
||||
|
||||
/** @type {HTMLDivElement | null} */
|
||||
_container: null,
|
||||
|
||||
_initialized: false,
|
||||
selected: new Set(),
|
||||
|
||||
@@ -72,11 +82,11 @@ const musicView = {
|
||||
|
||||
if (!resp.ok) throw new Error('Failed to load playlists');
|
||||
|
||||
this.playlists = await resp.json();
|
||||
this.playlists = /** @type {Playlist[]} */ (await resp.json());
|
||||
this._renderPlaylists();
|
||||
} catch (err) {
|
||||
console.error('Music load error:', err);
|
||||
this._showError(err.message);
|
||||
this._showError(/** @type {Error} */ (err).message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this._showLoading(false);
|
||||
@@ -209,7 +219,7 @@ const musicView = {
|
||||
)
|
||||
.join('');
|
||||
|
||||
listEl.querySelectorAll('.music-playlist-item').forEach((item) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (listEl.querySelectorAll('.music-playlist-item')).forEach((item) => {
|
||||
item.addEventListener('click', () => {
|
||||
const id = item.dataset.id;
|
||||
this._selectPlaylist(id);
|
||||
@@ -269,6 +279,11 @@ const musicView = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} playlistId
|
||||
* @returns
|
||||
*/
|
||||
async _selectPlaylist(playlistId) {
|
||||
const playlist = this.playlists.find((p) => p.id === playlistId);
|
||||
if (!playlist) return;
|
||||
@@ -308,13 +323,18 @@ const musicView = {
|
||||
togglePublicBtn.classList.toggle('active', playlist.is_public);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.music-playlist-item').forEach((item) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-playlist-item')).forEach((item) => {
|
||||
item.classList.toggle('active', item.dataset.id === playlistId);
|
||||
});
|
||||
|
||||
await this._loadPlaylistTracks(playlistId);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} playlistId
|
||||
* @returns
|
||||
*/
|
||||
async _loadPlaylistTracks(playlistId) {
|
||||
const trackListEl = document.getElementById('music-track-list');
|
||||
if (!trackListEl) return;
|
||||
@@ -333,7 +353,7 @@ const musicView = {
|
||||
this._renderTracks();
|
||||
} catch (err) {
|
||||
console.error('Track load error:', err);
|
||||
trackListEl.innerHTML = `<div class="music-error">${err.message}</div>`;
|
||||
trackListEl.innerHTML = `<div class="music-error">${/** @type {Error} */ (err).message}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -385,7 +405,7 @@ const musicView = {
|
||||
)
|
||||
.join('')}
|
||||
`;
|
||||
trackListEl.querySelectorAll('.music-track').forEach((row) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (trackListEl.querySelectorAll('.music-track')).forEach((row) => {
|
||||
row.addEventListener('click', () => {
|
||||
const idx = parseInt(row.dataset.idx, 10);
|
||||
// Toggle selection
|
||||
@@ -449,6 +469,11 @@ const musicView = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} idx
|
||||
* @returns
|
||||
*/
|
||||
_playTrack(idx) {
|
||||
if (!this.currentTracks[idx]) return;
|
||||
|
||||
@@ -487,8 +512,12 @@ const musicView = {
|
||||
this._createPlaylist(name.trim());
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} name
|
||||
*/
|
||||
async _createPlaylist(name) {
|
||||
const createBtn = document.getElementById('music-create-playlist-btn');
|
||||
const createBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-create-playlist-btn'));
|
||||
if (createBtn) createBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/playlists', {
|
||||
@@ -519,7 +548,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -542,7 +571,7 @@ const musicView = {
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const deleteBtn = document.getElementById('music-delete-playlist-btn');
|
||||
const deleteBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-delete-playlist-btn'));
|
||||
if (deleteBtn) deleteBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
|
||||
@@ -568,7 +597,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -576,6 +605,11 @@ const musicView = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number|null} secs
|
||||
* @returns
|
||||
*/
|
||||
_formatDuration(secs) {
|
||||
if (!secs) return '-';
|
||||
const mins = Math.floor(secs / 60);
|
||||
@@ -583,11 +617,20 @@ const musicView = {
|
||||
return `${mins}:${s.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string|null} str
|
||||
* @returns
|
||||
*/
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {boolean} show
|
||||
*/
|
||||
_showLoading(show) {
|
||||
const existing = this._container?.querySelector('.music-loading');
|
||||
if (show && !existing) {
|
||||
@@ -600,6 +643,11 @@ const musicView = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} message
|
||||
* @returns
|
||||
*/
|
||||
_showError(message) {
|
||||
if (!this._container) return;
|
||||
this._container.innerHTML = `
|
||||
@@ -647,7 +695,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -690,7 +738,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -731,8 +779,8 @@ const musicView = {
|
||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||
|
||||
const listEl = document.getElementById('music-picker-list');
|
||||
const queryInput = document.getElementById('music-picker-query');
|
||||
const addBtn = document.getElementById('music-picker-add-btn');
|
||||
const queryInput = /** @type {HTMLInputElement} */ (document.getElementById('music-picker-query'));
|
||||
const addBtn = /** @type {HTMLButtonElement} */ (document.getElementById('music-picker-add-btn'));
|
||||
const countEl = document.getElementById('music-picker-count');
|
||||
const selectedIds = new Set();
|
||||
|
||||
@@ -765,6 +813,11 @@ const musicView = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileItem[]} files
|
||||
* @returns
|
||||
*/
|
||||
const renderFiles = (files) => {
|
||||
if (files.length === 0) {
|
||||
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${i18n.t('music.no_audio_files')}</div>`;
|
||||
@@ -798,6 +851,7 @@ const musicView = {
|
||||
};
|
||||
|
||||
// ── Debounced search ──
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let searchTimer = null;
|
||||
queryInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
@@ -857,6 +911,12 @@ const musicView = {
|
||||
fetchAudioFiles();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} _trackId
|
||||
* @param {string} fileId
|
||||
* @returns
|
||||
*/
|
||||
async _removeTrackFromPlaylist(_trackId, fileId) {
|
||||
if (!this.currentPlaylist) return;
|
||||
|
||||
@@ -892,12 +952,18 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} fromIdx
|
||||
* @param {number} toIdx
|
||||
* @returns
|
||||
*/
|
||||
async _reorderTrack(fromIdx, toIdx) {
|
||||
if (!this.currentPlaylist) return;
|
||||
|
||||
@@ -923,7 +989,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
await this._loadPlaylistTracks(this.currentPlaylist.id);
|
||||
@@ -967,8 +1033,8 @@ const musicView = {
|
||||
});
|
||||
|
||||
dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => {
|
||||
const userInput = dialog.querySelector('#music-share-user-input');
|
||||
const writeInput = dialog.querySelector('#music-share-write-input');
|
||||
const userInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-user-input'));
|
||||
const writeInput = /** @type {HTMLInputElement} */ (dialog.querySelector('#music-share-write-input'));
|
||||
const userId = userInput.value.trim();
|
||||
if (!userId) return;
|
||||
|
||||
@@ -997,7 +1063,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1006,6 +1072,11 @@ const musicView = {
|
||||
this._loadSharesList(dialog);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLDivElement} dialog
|
||||
* @returns
|
||||
*/
|
||||
async _loadSharesList(dialog) {
|
||||
if (!this.currentPlaylist) return;
|
||||
const body = dialog.querySelector('.music-shares-body');
|
||||
@@ -1019,6 +1090,7 @@ const musicView = {
|
||||
headers: this._headers()
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to load shares');
|
||||
/** @type {Musicshare[]} */
|
||||
const shares = await resp.json();
|
||||
|
||||
if (shares.length === 0) {
|
||||
@@ -1040,16 +1112,22 @@ const musicView = {
|
||||
|
||||
body.querySelectorAll('.music-share-remove-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const item = btn.closest('.music-share-item');
|
||||
const item = /** @type {HTMLDivElement} */ (btn.closest('.music-share-item'));
|
||||
const userId = item.dataset.userId;
|
||||
await this._removeShare(userId, dialog);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(err.message)}</p>`;
|
||||
body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(/** @type {Error} */ (err).message)}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {HTMLDivElement} dialog
|
||||
* @returns
|
||||
*/
|
||||
async _removeShare(userId, dialog) {
|
||||
if (!this.currentPlaylist) return;
|
||||
|
||||
@@ -1067,7 +1145,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1115,7 +1193,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1183,7 +1261,7 @@ const musicView = {
|
||||
icon: 'fa-exclamation-circle',
|
||||
iconClass: 'error',
|
||||
title: i18n.t('music.error'),
|
||||
text: err.message
|
||||
text: /** @type {Error} */ (err).message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1198,10 +1276,15 @@ const musicView = {
|
||||
* Handles audio playback, queue, and controls
|
||||
*/
|
||||
const musicPlayer = {
|
||||
/** @type {HTMLAudioElement | null} */
|
||||
audio: null,
|
||||
|
||||
/** @type {PlaylistItem[]} */
|
||||
queue: [],
|
||||
currentIndex: -1,
|
||||
/** @type {PlaylistItem|null} */
|
||||
currentTrack: null,
|
||||
|
||||
isPlaying: false,
|
||||
volume: 0.7,
|
||||
isMuted: false,
|
||||
@@ -1306,7 +1389,7 @@ const musicPlayer = {
|
||||
const shuffleBtn = document.getElementById('player-shuffle-btn');
|
||||
const repeatBtn = document.getElementById('player-repeat-btn');
|
||||
const progressBar = document.getElementById('player-progress-bar');
|
||||
const volumeInput = document.getElementById('player-volume-input');
|
||||
const volumeInput = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
|
||||
const volBtn = document.getElementById('player-vol-btn');
|
||||
const playlistBtn = document.getElementById('player-playlist-btn');
|
||||
const closeQueueBtn = document.getElementById('player-close-queue-btn');
|
||||
@@ -1338,7 +1421,8 @@ const musicPlayer = {
|
||||
|
||||
if (volumeInput) {
|
||||
volumeInput.addEventListener('input', (e) => {
|
||||
this.setVolume(e.target.value / 100);
|
||||
const target = /** @type {HTMLInputElement} */ (e.target);
|
||||
this.setVolume(parseFloat(target.value) / 100);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1381,12 +1465,22 @@ const musicPlayer = {
|
||||
document.body.classList.remove('music-player-active');
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PlaylistItem[]} tracks
|
||||
* @param {string} playlistName
|
||||
*/
|
||||
setQueue(tracks, playlistName = '') {
|
||||
this.queue = [...tracks];
|
||||
this.playlistName = playlistName;
|
||||
this._updateQueueUI();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} index
|
||||
* @returns
|
||||
*/
|
||||
playTrack(index) {
|
||||
if (index < 0 || index >= this.queue.length) return;
|
||||
|
||||
@@ -1488,15 +1582,19 @@ const musicPlayer = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} vol
|
||||
*/
|
||||
setVolume(vol) {
|
||||
this.volume = Math.max(0, Math.min(1, vol));
|
||||
this.audio.volume = this.volume;
|
||||
this.isMuted = this.volume === 0;
|
||||
this._updateVolumeIcon();
|
||||
|
||||
const input = document.getElementById('player-volume-input');
|
||||
const input = /** @type {HTMLInputElement} */ (document.getElementById('player-volume-input'));
|
||||
if (input) {
|
||||
input.value = this.volume * 100;
|
||||
input.value = String(this.volume * 100);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1522,6 +1620,11 @@ const musicPlayer = {
|
||||
btn.querySelector('i').className = `fas ${icon}`;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PointerEvent} e
|
||||
* @returns
|
||||
*/
|
||||
_seek(e) {
|
||||
const bar = document.getElementById('player-progress-bar');
|
||||
if (!bar) return;
|
||||
@@ -1596,6 +1699,10 @@ const musicPlayer = {
|
||||
this._updateUI();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ErrorEvent} e
|
||||
*/
|
||||
_onError(e) {
|
||||
console.error('Audio error:', e);
|
||||
this.isPlaying = false;
|
||||
@@ -1624,7 +1731,8 @@ const musicPlayer = {
|
||||
if (oxiIcon) {
|
||||
icon.outerHTML = oxiIcon(iconName, extraClass);
|
||||
} else {
|
||||
icon.className = `fas fa-${iconName} ${extraClass}`;
|
||||
icon.classList.remove(...icon.classList);
|
||||
icon.classList.add('fas', `fa-${iconName}`, `${extraClass}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1640,7 +1748,7 @@ const musicPlayer = {
|
||||
}
|
||||
|
||||
if (musicView.currentTracks.length > 0) {
|
||||
document.querySelectorAll('.music-track').forEach((row) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.music-track')).forEach((row) => {
|
||||
const idx = parseInt(row.dataset.idx, 10);
|
||||
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
|
||||
|
||||
@@ -1710,15 +1818,16 @@ const musicPlayer = {
|
||||
)
|
||||
.join('');
|
||||
|
||||
queueList.querySelectorAll('.player-queue-item').forEach((item) => {
|
||||
/** @type {NodeListOf<HTMLDivElement>} */ (queueList.querySelectorAll('.player-queue-item')).forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.queue-item-remove')) return;
|
||||
const target = /** @type {Element} */ (e.target);
|
||||
if (target.closest('.queue-item-remove')) return;
|
||||
const idx = parseInt(item.dataset.idx, 10);
|
||||
this.playTrack(idx);
|
||||
});
|
||||
});
|
||||
|
||||
queueList.querySelectorAll('.queue-item-remove').forEach((btn) => {
|
||||
/** @type {NodeListOf<HTMLButtonElement>} */ (queueList.querySelectorAll('.queue-item-remove')).forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
@@ -1727,6 +1836,10 @@ const musicPlayer = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} idx
|
||||
*/
|
||||
_removeFromQueue(idx) {
|
||||
if (idx === this.currentIndex) {
|
||||
if (this.queue.length === 1) {
|
||||
@@ -1754,6 +1867,10 @@ const musicPlayer = {
|
||||
this._updateUI();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {boolean|undefined} [show]
|
||||
*/
|
||||
_toggleQueue(show) {
|
||||
const queue = document.getElementById('player-queue');
|
||||
if (queue) {
|
||||
@@ -1765,6 +1882,11 @@ const musicPlayer = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number|null} secs
|
||||
* @returns {String}
|
||||
*/
|
||||
_formatTime(secs) {
|
||||
if (!secs || Number.isNaN(secs)) return '0:00';
|
||||
const mins = Math.floor(secs / 60);
|
||||
@@ -1772,10 +1894,19 @@ const musicPlayer = {
|
||||
return `${mins}:${s.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number|null} secs
|
||||
* @returns {String}
|
||||
*/
|
||||
_formatDuration(secs) {
|
||||
return this._formatTime(secs);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string|null} str
|
||||
* @returns {String}
|
||||
*/
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
@@ -8,10 +8,14 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { thumbnail } from '../thumbnail.js';
|
||||
import { photosLightbox } from './photosLightbox.js';
|
||||
|
||||
/** @import {FileInfo} from '../../core/types.js' */
|
||||
/** @import {FileItem} from '../../core/types.js' */
|
||||
|
||||
/**
|
||||
* @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum
|
||||
*/
|
||||
|
||||
const photosView = {
|
||||
/** @type {Array} All loaded photo items */
|
||||
/** @type {Array<FileItem>} All loaded photo items */
|
||||
items: [],
|
||||
/** @type {string|null} Cursor for next page */
|
||||
nextCursor: null,
|
||||
@@ -27,7 +31,7 @@ const photosView = {
|
||||
_container: null,
|
||||
/** @type {boolean} */
|
||||
_initialized: false,
|
||||
/** @type {'daily'|'monthly'|'yearly'} */
|
||||
/** @type {PhotoModeEnum} */
|
||||
groupMode: 'monthly',
|
||||
/** @type {Map<string, string>} fileId → thumbnail URL (persists across re-renders) */
|
||||
_videoThumbCache: new Map(),
|
||||
@@ -84,6 +88,11 @@ const photosView = {
|
||||
},
|
||||
|
||||
/** Switch grouping mode */
|
||||
/**
|
||||
*
|
||||
* @param {PhotoModeEnum} mode
|
||||
* @returns
|
||||
*/
|
||||
setGroupMode(mode) {
|
||||
if (this.groupMode === mode) return;
|
||||
this.groupMode = mode;
|
||||
@@ -112,6 +121,7 @@ const photosView = {
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
@@ -178,7 +188,9 @@ const photosView = {
|
||||
|
||||
/** Append-only render for infinite scroll — inserts only the items
|
||||
* from this.items[startIndex..] without destroying existing DOM.
|
||||
* Complexity: O(batch) instead of O(total_items). */
|
||||
* Complexity: O(batch) instead of O(total_items).
|
||||
* @param {number} startIndex
|
||||
*/
|
||||
_appendBatch(startIndex) {
|
||||
if (!this._container) return;
|
||||
this._destroyObserver();
|
||||
@@ -227,7 +239,10 @@ const photosView = {
|
||||
this._setupVideoThumbnails(startIndex);
|
||||
},
|
||||
|
||||
/** Generate HTML for a single photo/video tile */
|
||||
/**
|
||||
* Generate HTML for a single photo/video tile
|
||||
* @param {FileItem} file
|
||||
*/
|
||||
_renderTile(file) {
|
||||
const isVideo = file.mime_type?.startsWith('video/');
|
||||
const selected = this.selected.has(file.id) ? ' selected' : '';
|
||||
@@ -266,7 +281,7 @@ 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 = /** @type {NodeListOf<HTMLDivElement> */ (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;
|
||||
@@ -290,11 +305,15 @@ const photosView = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate(). */
|
||||
/**
|
||||
* Extract a frame and upload all thumbnail sizes via thumbnail.queueGenerate().
|
||||
* @param {HTMLDivElement} tile
|
||||
* @param {HTMLImageElement} img
|
||||
*/
|
||||
async _generateVideoThumbnail(tile, img) {
|
||||
const fileId = tile.dataset.id;
|
||||
// TODO: remove this HACK, this is not evolutive...
|
||||
const file = /** @type {FileInfo} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime });
|
||||
const file = /** @type {FileItem} */ ({ id: fileId, icon_special_class: 'video-icon', name: tile.dataset.name, mime_type: tile.dataset.mime });
|
||||
|
||||
try {
|
||||
await thumbnail.queueGenerate(file, null, (previewDataUrl) => {
|
||||
@@ -335,7 +354,10 @@ const photosView = {
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/** Group items by the current groupMode */
|
||||
/**
|
||||
* Group items by the current groupMode
|
||||
* @param {FileItem[]} items
|
||||
*/
|
||||
_groupItems(items) {
|
||||
const map = new Map();
|
||||
for (const item of items) {
|
||||
@@ -363,20 +385,24 @@ const photosView = {
|
||||
return map;
|
||||
},
|
||||
|
||||
/** Handle click on photo tile or toolbar */
|
||||
/**
|
||||
* Handle click on photo tile or toolbar
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
_handleClick(e) {
|
||||
// Handle group mode toggle
|
||||
const modeBtn = e.target.closest('[data-group-mode]');
|
||||
const target = /** @type {Element} */ (e.target);
|
||||
const modeBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-group-mode]'));
|
||||
if (modeBtn) {
|
||||
this.setGroupMode(modeBtn.dataset.groupMode);
|
||||
this.setGroupMode(/** @type {PhotoModeEnum} */ (modeBtn.dataset.groupMode));
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = e.target.closest('.photo-tile');
|
||||
const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile'));
|
||||
if (!tile) return;
|
||||
|
||||
const id = tile.dataset.id;
|
||||
const check = e.target.closest('.photo-check');
|
||||
const check = target.closest('.photo-check');
|
||||
|
||||
// If clicking checkbox or in selection mode, toggle select
|
||||
if (check || this.selected.size > 0) {
|
||||
@@ -391,7 +417,11 @@ const photosView = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Toggle selection of an item */
|
||||
/**
|
||||
* Toggle selection of an item
|
||||
* @param {string} id
|
||||
* @param {HTMLDivElement} tile
|
||||
*/
|
||||
_toggleSelect(id, tile) {
|
||||
if (this.selected.has(id)) {
|
||||
this.selected.delete(id);
|
||||
@@ -483,6 +513,7 @@ const photosView = {
|
||||
if (bar) bar.style.display = 'none';
|
||||
},
|
||||
|
||||
/** @param {boolean} show */
|
||||
_showLoading(show) {
|
||||
if (!this._container) return;
|
||||
let loader = this._container.querySelector('.photos-loading');
|
||||
@@ -503,12 +534,14 @@ const photosView = {
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escAttr(s) {
|
||||
return String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { favorites } from '../library/favorites.js';
|
||||
|
||||
/** @import {FileItem, FileMetadata} from '../../core/types.js' */
|
||||
/** @typedef {typeof import('./photos.js').photosView} PhotosView */
|
||||
|
||||
export const photosLightbox = {
|
||||
/** @type {Array} Items array reference */
|
||||
/** @type {Array<FileItem>} Items array reference */
|
||||
items: [],
|
||||
/** @type {number} Current index */
|
||||
index: -1,
|
||||
@@ -15,14 +18,14 @@ export const photosLightbox = {
|
||||
_overlay: null,
|
||||
/** @type {string|null} Current blob URL to revoke */
|
||||
_blobUrl: null,
|
||||
/** @type {Function|null} */
|
||||
/** @type {(ev: KeyboardEvent) => any|null} */
|
||||
_keyHandler: null,
|
||||
/** @type {Object|null} Reference to photosView, set after both modules load */
|
||||
/** @type {PhotosView|null} Reference to photosView, set after both modules load */
|
||||
_photosView: null,
|
||||
|
||||
/**
|
||||
* Register the photosView reference (called from photos.js to avoid circular imports).
|
||||
* @param {Object} pv
|
||||
* @param {any} pv
|
||||
*/
|
||||
setPhotosView(pv) {
|
||||
this._photosView = pv;
|
||||
@@ -33,7 +36,11 @@ export const photosLightbox = {
|
||||
return getCsrfHeaders();
|
||||
},
|
||||
|
||||
/** Open lightbox at given index */
|
||||
/**
|
||||
* Open lightbox at given index
|
||||
* @param {FileItem[]} items
|
||||
* @param {number} index
|
||||
*/
|
||||
open(items, index) {
|
||||
this.items = items;
|
||||
this.index = index;
|
||||
@@ -99,21 +106,21 @@ export const photosLightbox = {
|
||||
this._overlay = el;
|
||||
|
||||
// Event listeners
|
||||
el.querySelector('.lightbox-close').onclick = () => this.close();
|
||||
el.querySelector('.lightbox-prev').onclick = () => this.prev();
|
||||
el.querySelector('.lightbox-next').onclick = () => this.next();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-close')).onclick = () => this.close();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-prev')).onclick = () => this.prev();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lightbox-next')).onclick = () => this.next();
|
||||
|
||||
// Click backdrop to close
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === el || e.target.classList.contains('lightbox-content')) {
|
||||
if (e.target === el || /** @type {HTMLElement} */ (e.target).classList.contains('lightbox-content')) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar actions
|
||||
el.querySelector('.lb-download').onclick = () => this._download();
|
||||
el.querySelector('.lb-favorite').onclick = () => this._toggleFavorite();
|
||||
el.querySelector('.lb-delete').onclick = () => this._delete();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite();
|
||||
/** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete();
|
||||
|
||||
// Animate in
|
||||
requestAnimationFrame(() => el.classList.add('active'));
|
||||
@@ -144,8 +151,8 @@ export const photosLightbox = {
|
||||
meta.textContent = `${dateStr} · ${item.size_formatted || ''}`;
|
||||
|
||||
// Update nav button visibility
|
||||
this._overlay.querySelector('.lightbox-prev').style.visibility = this.index > 0 ? 'visible' : 'hidden';
|
||||
this._overlay.querySelector('.lightbox-next').style.visibility = this.index < this.items.length - 1 ? 'visible' : 'hidden';
|
||||
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-prev')).classList.toggle('hidden', !(this.index > 0));
|
||||
/** @type {HTMLButtonElement} */ (this._overlay.querySelector('.lightbox-next')).classList.toggle('hidden', !(this.index < this.items.length - 1));
|
||||
|
||||
// Load content
|
||||
this._revokeBlob();
|
||||
@@ -176,7 +183,13 @@ export const photosLightbox = {
|
||||
this._loadMetadata(item.id, meta, dateStr, item.size_formatted || '');
|
||||
},
|
||||
|
||||
/** Load EXIF metadata for info bar */
|
||||
/**
|
||||
* Load EXIF metadata for info bar
|
||||
* @param {string} fileId
|
||||
* @param {Element} metaEl
|
||||
* @param {string} dateStr
|
||||
* @param {string} sizeStr
|
||||
*/
|
||||
async _loadMetadata(fileId, metaEl, dateStr, sizeStr) {
|
||||
try {
|
||||
const res = await fetch(`/api/files/${fileId}/metadata`, {
|
||||
@@ -184,16 +197,18 @@ export const photosLightbox = {
|
||||
headers: this._headers()
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const metadata = /** @type {FileMetadata} */ (await res.json());
|
||||
const parts = [dateStr];
|
||||
if (sizeStr) parts.push(sizeStr);
|
||||
if (data.camera_make || data.camera_model) {
|
||||
parts.push([data.camera_make, data.camera_model].filter(Boolean).join(' '));
|
||||
if (metadata.camera_make || metadata.camera_model) {
|
||||
parts.push([metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '));
|
||||
}
|
||||
if (data.width && data.height) {
|
||||
parts.push(`${data.width}×${data.height}`);
|
||||
if (metadata.width && metadata.height) {
|
||||
parts.push(`${metadata.width}×${metadata.height}`);
|
||||
}
|
||||
metaEl.textContent = parts.join(' · ');
|
||||
|
||||
//TODO: add geoloc pointer to openstreetmap ?
|
||||
}
|
||||
} catch (_err) {
|
||||
// Non-critical, keep existing meta
|
||||
@@ -220,7 +235,7 @@ export const photosLightbox = {
|
||||
await fetch(`/api/favorites/file/${item.id}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this._headers(true)
|
||||
headers: this._headers()
|
||||
});
|
||||
const btn = this._overlay.querySelector('.lb-favorite');
|
||||
if (btn) {
|
||||
@@ -254,11 +269,11 @@ export const photosLightbox = {
|
||||
this.items.splice(this.index, 1);
|
||||
if (this.items.length === 0) {
|
||||
this.close();
|
||||
if (this._photosView) this._photosView._render();
|
||||
if (this._photosView) this._photosView._renderFull(); // will call renderEmpty() on this case
|
||||
} else {
|
||||
if (this.index >= this.items.length) this.index = this.items.length - 1;
|
||||
this._show();
|
||||
if (this._photosView) this._photosView._render();
|
||||
if (this._photosView) this._photosView._renderFull();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
@@ -289,6 +304,7 @@ export const photosLightbox = {
|
||||
}
|
||||
},
|
||||
|
||||
/** @param {any} s */
|
||||
_escAttr(s) {
|
||||
return String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
@@ -12,6 +12,8 @@ import { i18n } from '../../core/i18n.js';
|
||||
import { multiSelect } from '../files/multiSelect.js';
|
||||
import * as pathTooltip from '../pathTooltip.js';
|
||||
|
||||
/** @import {FileItem, FolderItem, ItemTypeEnum} from '../../core/types.js' */
|
||||
|
||||
const recent = {
|
||||
/** Maximum items to request from the server */
|
||||
MAX_RECENT_FILES: 20,
|
||||
@@ -38,8 +40,9 @@ const recent = {
|
||||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('file-accessed', (event) => {
|
||||
if (event.detail?.file) {
|
||||
const file = event.detail.file;
|
||||
const e = /** @type {CustomEvent} */ (event);
|
||||
if (e.detail?.file) {
|
||||
const file = e.detail.file;
|
||||
const itemType = file.item_type || 'file';
|
||||
this._recordAccess(file.id, itemType);
|
||||
}
|
||||
@@ -48,6 +51,8 @@ const recent = {
|
||||
|
||||
/**
|
||||
* Record an access event on the server.
|
||||
* @param {string} itemId
|
||||
* @param {ItemTypeEnum} itemType
|
||||
*/
|
||||
async _recordAccess(itemId, itemType) {
|
||||
try {
|
||||
@@ -120,8 +125,12 @@ const recent = {
|
||||
`);
|
||||
}
|
||||
|
||||
/** @type {FolderItem[]} */
|
||||
const folders = [];
|
||||
|
||||
/** @type {FileItem[]} */
|
||||
const files = [];
|
||||
|
||||
for (const item of recentItems) {
|
||||
const isFolder = item.item_type === 'folder';
|
||||
if (isFolder) {
|
||||
@@ -130,7 +139,13 @@ const recent = {
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || ''
|
||||
path: item.item_path || '',
|
||||
category: 'folder',
|
||||
created_at: item.created_at,
|
||||
icon_class: '',
|
||||
icon_special_class: '',
|
||||
owner_id: '',
|
||||
is_root: false
|
||||
});
|
||||
} else {
|
||||
files.push({
|
||||
@@ -144,7 +159,10 @@ const recent = {
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.accessed_at,
|
||||
path: item.item_path || ''
|
||||
path: item.item_path || '',
|
||||
owner_id: '',
|
||||
created_at: item.created_at,
|
||||
sort_date: item.created_at
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ function _onLeave() {
|
||||
_tooltip?.classList.add('hidden');
|
||||
}
|
||||
|
||||
/** @type {WeakMap<HTMLElement, {enter: Function, leave: Function}>} */
|
||||
/**
|
||||
* @typedef {Object} EnterLeaveF
|
||||
* @property {(e: MouseEvent) => void} enter
|
||||
* @property {(e: MouseEvent) => void} leave
|
||||
*
|
||||
|
||||
/** @type {WeakMap<HTMLElement, EnterLeaveF>} */
|
||||
const _listeners = new WeakMap();
|
||||
|
||||
/**
|
||||
@@ -49,10 +55,15 @@ function init(container) {
|
||||
const items = container.querySelectorAll('.file-item[data-path]');
|
||||
items.forEach((item) => {
|
||||
const el = /** @type {HTMLElement} */ (item);
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const enter = (e) => _onEnter(e);
|
||||
const leave = () => _onLeave();
|
||||
el.addEventListener('mouseenter', enter);
|
||||
|
||||
/** @type {(e: MouseEvent) => void} */
|
||||
const leave = (_e) => _onLeave();
|
||||
el.addEventListener('mouseleave', leave);
|
||||
|
||||
_listeners.set(el, { enter, leave });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ui } from '../../app/ui.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { formatDateTime } from '../../core/formatters.js';
|
||||
|
||||
/**
|
||||
* @import {CreateShare, ShareItem, UpdateShare} from '../../core/types.js'
|
||||
*/
|
||||
|
||||
const fileSharing = {
|
||||
/** Auth header helper — tokens are in HttpOnly cookies now */
|
||||
_headers(json = true) {
|
||||
@@ -21,16 +25,17 @@ const fileSharing = {
|
||||
* Create a shared link via backend API
|
||||
* @param {string} itemId - ID of the file or folder
|
||||
* @param {string} itemType - 'file' or 'folder'
|
||||
* @param {Object} options - { name, password, expirationDate, permissions }
|
||||
* @param {CreateShare} [options] -
|
||||
* @returns {Promise<Object>} ShareDto from backend
|
||||
*/
|
||||
async createSharedLink(itemId, itemType, options = {}) {
|
||||
// FIXME unused ?? duplicate with createSharedLink() from contextMenu
|
||||
async createSharedLink(itemId, itemType, options) {
|
||||
const body = {
|
||||
item_id: itemId,
|
||||
item_name: options.name || null,
|
||||
item_name: options.item_name || null,
|
||||
item_type: itemType,
|
||||
password: options.password || null,
|
||||
expires_at: options.expirationDate ? Math.floor(new Date(options.expirationDate).getTime() / 1000) : null,
|
||||
expires_at: options.expires_at ? Math.floor(new Date(options.expires_at).getTime() / 1000) : null,
|
||||
permissions: options.permissions || {
|
||||
read: true,
|
||||
write: false,
|
||||
@@ -54,7 +59,7 @@ const fileSharing = {
|
||||
|
||||
/**
|
||||
* Get all shared links for the current user
|
||||
* @returns {Promise<Array>} Array of ShareDto
|
||||
* @returns {Promise<ShareItem[]>} Array of ShareDto
|
||||
*/
|
||||
async getSharedLinks() {
|
||||
try {
|
||||
@@ -62,7 +67,7 @@ const fileSharing = {
|
||||
headers: this._headers(false)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const data = /** @type {ShareItem[]} */ await res.json();
|
||||
return data.items || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching shared links:', error);
|
||||
@@ -74,7 +79,7 @@ const fileSharing = {
|
||||
* Get shared links for a specific item (server-side filtered)
|
||||
* @param {string} itemId
|
||||
* @param {string} itemType - 'file' or 'folder'
|
||||
* @returns {Promise<Array>} Shares for this item
|
||||
* @returns {Promise<ShareItem[]>} Shares for this item
|
||||
*/
|
||||
async getSharedLinksForItem(itemId, itemType) {
|
||||
try {
|
||||
@@ -96,6 +101,8 @@ const fileSharing = {
|
||||
|
||||
/**
|
||||
* Check if an item has any shared links
|
||||
* @param {string} itemId
|
||||
* @param {string} itemType
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async hasSharedLinks(itemId, itemType) {
|
||||
@@ -106,7 +113,7 @@ const fileSharing = {
|
||||
/**
|
||||
* Update a shared link
|
||||
* @param {string} shareId
|
||||
* @param {Object} updateData - { permissions, password, expires_at }
|
||||
* @param {UpdateShare} updateData - { permissions, password, expires_at }
|
||||
* @returns {Promise<Object>} Updated ShareDto
|
||||
*/
|
||||
async updateSharedLink(shareId, updateData) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { getCsrfHeaders } from '../core/csrf.js';
|
||||
|
||||
/** @import {FileInfo} from '../core/types.js' */
|
||||
/** @import {FileItem} from '../core/types.js' */
|
||||
|
||||
/** @type {typeof import('../vendors/pdf.min.d.ts') | null} */
|
||||
/**
|
||||
* use any type so tsc will not scan library
|
||||
* @type {any}
|
||||
*/
|
||||
let _pdfjsLib = null;
|
||||
|
||||
// TODO: do we need to add a max concurrncy ?
|
||||
@@ -10,11 +13,13 @@ let _pdfjsLib = null;
|
||||
/**
|
||||
* Lazy-loads pdf.min.mjs on first use via dynamic import so it is never
|
||||
* bundled into the IIFE (it uses top-level await which breaks IIFE wrapping).
|
||||
* @returns {Promise<typeof import('../vendors/pdf.min.d.ts')>}
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function getPdfjsLib() {
|
||||
if (_pdfjsLib) return _pdfjsLib;
|
||||
_pdfjsLib = await import('/js/vendors/pdf.min.mjs');
|
||||
// IMPORTANT: this hack (const lib=...) so tsc will not load vendors library
|
||||
const lib = '../vendors/pdf.min.mjs';
|
||||
_pdfjsLib = /** @type {any} */ (await import(lib));
|
||||
_pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/vendors/pdf.worker.min.mjs';
|
||||
return _pdfjsLib;
|
||||
}
|
||||
@@ -23,7 +28,7 @@ export const thumbnail = {
|
||||
SUPPORTED_MIME_TYPE: [/^image\//, /^application\/pdf$/, /^video\//],
|
||||
/**
|
||||
*
|
||||
* @param {Object} file
|
||||
* @param {FileItem} file
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canHandle(file) {
|
||||
@@ -109,7 +114,7 @@ export const thumbnail = {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FileInfo} file
|
||||
* @param {FileItem} file
|
||||
* @param {string} source
|
||||
* @returns {Promise<ImageBitmap>}
|
||||
*
|
||||
@@ -163,7 +168,7 @@ export const thumbnail = {
|
||||
/**
|
||||
* generateThumbnail and update image
|
||||
*
|
||||
* @param {Object} file the source of the image
|
||||
* @param {FileItem} file the source of the image
|
||||
* @param {((dataURL: string) => void) | null} [onIconGenerated] the callback once thumbnail is generated
|
||||
* @param {((dataURL: string) => void) | null} [onPreviewGenerated] the callback once thumbnail is generated
|
||||
*
|
||||
@@ -203,7 +208,7 @@ export const thumbnail = {
|
||||
|
||||
MAX_CONCURRENT: 3,
|
||||
_activeGenerates: 0,
|
||||
/** @type {Array<() => void>} */
|
||||
/** @type {Array<(resolve: any) => void>} */
|
||||
_generateQueue: [],
|
||||
|
||||
/**
|
||||
@@ -211,7 +216,7 @@ export const thumbnail = {
|
||||
* At most MAX_CONCURRENT generations run simultaneously; excess calls are
|
||||
* queued and resume automatically as slots free up.
|
||||
*
|
||||
* @param {FileInfo} file
|
||||
* @param {FileItem} file
|
||||
* @param {((dataURL: string) => void) | null} [onIconGenerated]
|
||||
* @param {((dataURL: string) => void) | null} [onPreviewGenerated]
|
||||
* @returns {Promise<void>}
|
||||
@@ -224,7 +229,7 @@ export const thumbnail = {
|
||||
try {
|
||||
await this._generate(file, onIconGenerated, onPreviewGenerated);
|
||||
} catch (err) {
|
||||
if (err instanceof Event) {
|
||||
if (err instanceof Event && 'error' in err.target) {
|
||||
console.warn(`generation of thumbnail for ${file.name} failed: `, err.target.error);
|
||||
} else if (err instanceof Error) {
|
||||
console.warn(`generation of thumbnail for ${file.name} failed: `, err.message);
|
||||
|
||||
Reference in New Issue
Block a user