Files
Oxicloud/static/js/app/main.js
T
2026-04-16 23:13:08 +02:00

747 lines
25 KiB
JavaScript

/**
* OxiCloud - Main Application
* This file contains the core functionality, initialization and state management
*/
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { Modal } from '../core/modal.js';
import { fileOps } from '../features/files/fileOperations.js';
import { multiSelect } from '../features/files/multiSelect.js';
import { favorites } from '../features/library/favorites.js';
import { recent } from '../features/library/recent.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { sharedView } from '../views/shared/sharedView.js';
import { checkAuthentication } from './authSession.js';
import { loadFiles } from './filesView.js';
import {
SECTIONS_MAPPER,
switchToFavoritesSection,
switchToFilesSection,
switchToMusicSection,
switchToPhotosSection,
switchToRecentFilesSection,
switchToSharedSection,
switchToTrashSection
} from './navigation.js';
import { performSearch } from './searchView.js';
import { app, appElements as elements } from './state.js';
import { loadTrashItems } from './trashView.js';
import { ui } from './ui.js';
import { setupUserMenu } from './userMenu.js';
// Upload dropdown listener state (prevents accumulated listeners)
/** @type { function | null } */
let uploadDropdownDocumentClickHandler = null;
/** @type { AbortController | null } */
let uploadDropdownBindingsController = null;
let actionsBarDelegationBound = false;
const _multiSelectButons = `
<div class="action-buttons batch-selection-bar hidden" id="multi-select-buttons">
<div class="list-header-checkbox">
<button class="batch-bar-close" id="batch-selection-close" title="Cancel selection">
<i class="fas fa-times"></i>
</button>
<span class="batch-bar-count" id="batch-bar-count"></span>
</div>
<div class="batch-selection-info">
<div class="batch-bar-actions">
<button class="batch-btn" id="batch-fav" title="Add to favorites" data-i18n-title="batch.add_favorites">
<i class="fas fa-star"></i>
<span data-i18n="batch.add_favorites">Add to favorites</span>
</button>
<button class="batch-btn" id="batch-move" title="Move or copy" data-i18n-title="batch.move_copy">
<i class="fas fa-arrows-alt"></i>
<span data-i18n="batch.move_copy">Move or copy</span>
</button>
<button class="batch-btn" id="batch-download" title="Download" data-i18n-title="actions.download">
<i class="fas fa-download"></i>
<span data-i18n="actions.download">Download</span>
</button>
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete" data-i18-title="actions.delete">
<i class="fas fa-trash-alt"></i>
<span data-i18n="actions.delete">Delete</span>
</button>
</div>
</div>
</div>
`;
const _toggleButtons = `
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Grid view">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="List view">
<i class="fas fa-list"></i>
</button>
</div>
`;
const ACTIONS_BAR_TEMPLATES = {
files: `
<div class="action-buttons" id="default-buttons">
<div class="upload-dropdown" id="upload-dropdown">
<button class="btn btn-primary" id="upload-btn">
<i class="fas fa-cloud-upload-alt icon-mr"></i>
<span data-i18n="actions.upload">Upload</span>
<i class="fas fa-caret-down icon-ml"></i>
</button>
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
<button class="upload-dropdown-item" id="upload-files-btn">
<i class="fas fa-file"></i>
<span data-i18n="actions.upload_files">Upload files</span>
</button>
<button class="upload-dropdown-item" id="upload-folder-btn">
<i class="fas fa-folder-open"></i>
<span data-i18n="actions.upload_folder">Upload folder</span>
</button>
</div>
</div>
<button class="btn btn-secondary" id="new-folder-btn">
<i class="fas fa-folder-plus icon-mr"></i>
<span data-i18n="actions.new_folder">New folder</span>
</button>
</div>
${_multiSelectButons}
${_toggleButtons}
`,
trash: `
<div class="action-buttons" id="default-buttons">
<button class="btn btn-danger" id="empty-trash-btn">
<i class="fas fa-trash-alt"></i>
<span data-i18n="trash.empty_trash">Empty trash</span>
</button>
</div>
${_toggleButtons}
`,
favorites: `
<div class="action-buttons" id="default-buttons"></div>
${_multiSelectButons}
${_toggleButtons}
`,
recent: `
<div class="action-buttons" id="default-buttons">
<button class="btn btn-secondary" id="clear-recent-btn">
<i class="fas fa-broom icon-mr"></i>
<span data-i18n="actions.clear_recent">Clear recent</span>
</button>
</div>
${_multiSelectButons}
${_toggleButtons}
`
};
/**
*
* @param {string} mode
* @param {boolean} [force=false]
* @returns
*/
function setActionsBarMode(mode, force = false) {
if (!elements.actionsBar) return;
if (mode === 'hidden') {
elements.actionsBar.classList.add('hidden');
elements.actionsBar.dataset.mode = 'hidden';
return;
}
if (!force && elements.actionsBar.dataset.mode === mode) {
return;
}
const html = ACTIONS_BAR_TEMPLATES[mode];
if (!html) return;
elements.actionsBar.innerHTML = html;
elements.actionsBar.classList.remove('hidden');
elements.actionsBar.dataset.mode = mode;
// Refresh cached action elements after rebuild
elements.uploadBtn = document.getElementById('upload-btn');
elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
if (i18n?.translateElement) {
i18n.translateElement(elements.actionsBar);
}
if (mode === 'files') {
setupUploadDropdown();
}
}
function setupActionsBarDelegation() {
if (actionsBarDelegationBound || !elements.actionsBar) return;
actionsBarDelegationBound = true;
elements.actionsBar.addEventListener('click', async (e) => {
const btn = e.target.closest('button');
if (!btn) return;
switch (btn.id) {
case 'upload-files-btn': {
e.stopPropagation();
const menu = document.getElementById('upload-dropdown-menu');
if (menu) menu.classList.remove('show');
if (elements.fileInput) elements.fileInput.click();
break;
}
case 'upload-folder-btn': {
e.stopPropagation();
const menu = document.getElementById('upload-dropdown-menu');
if (menu) menu.classList.remove('show');
const folderInput = document.getElementById('folder-input');
if (folderInput) folderInput.click();
break;
}
case 'new-folder-btn': {
const folderName = await Modal.promptNewFolder();
if (folderName) {
fileOps.createFolder(folderName);
}
break;
}
case 'grid-view-btn':
ui.switchToGridView();
break;
case 'list-view-btn':
ui.switchToListView();
break;
case 'empty-trash-btn':
if (await fileOps.emptyTrash()) {
loadTrashItems();
}
break;
case 'clear-recent-btn':
if (recent) {
recent.clearRecentFiles();
recent.displayRecentFiles();
ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
}
break;
default:
break;
}
});
}
/**
* @typedef {Object} OxiContext
* @property {string} section
* @property {string | null} path the uuid of the path
* @property {string | null} file the uuid of file inline view
*/
/**
* Read the application hash
*
* format:
*
* #/<section>/
*
* #/shared
* #/recent
* ...
*
* special case of drive:
*
* #/files/folder/<folder ID>
*
* @returns {OxiContext}
*/
function deserializeHash() {
const hashContext = /** type {OxiContext} */ {};
// FIXME rename files into drive ?
hashContext.section = 'files';
const hash_elements = window.location.hash.split('/');
const section = hash_elements[1];
if (section in SECTIONS_MAPPER) {
hashContext.section = section;
}
if (hash_elements[1] === 'files' && hash_elements[2] === 'folder' && hash_elements[3] !== null) {
hashContext.path = hash_elements[3];
if (hash_elements[4] === 'file' && hash_elements[5] !== null) {
hashContext.file = hash_elements[5];
}
}
return hashContext;
}
/**
* update borwser's url/history
*
* @param {boolean} insertHistory true to change url and browser's history, false to change url only
*/
function updateHistory(insertHistory) {
const historyData = {
section: app.currentSection,
id: app.currentFolder,
file: app.viewFile
};
let historyUrl = `#/${app.currentSection}`;
if (app.currentSection === 'files' && app.currentFolderInfo !== null) {
historyData.id = app.currentFolder;
historyUrl = historyUrl.concat('/folder/', app.currentFolderInfo.id);
if (app.viewFile) {
historyUrl = historyUrl.concat('/file/', app.viewFile);
}
// update title
document.title = `OxiCloud: ${app.currentFolderInfo.path}`;
}
if (insertHistory) {
console.log(`adding history with ${historyUrl}`);
window.history.pushState(historyData, '', historyUrl);
} else {
console.log(`replace history with ${historyUrl}`);
window.history.replaceState(historyData, '', historyUrl);
}
}
/**
*
* @param {string} section
* @returns
*/
function switchSectionTo(section) {
if (app.currentSection === section)
// no change ...
return;
if ((!section) in SECTIONS_MAPPER) {
console.warn(`context view ${section} unkonwn fallback to files section`);
section = 'files';
}
const switchHandler = SECTIONS_MAPPER[section];
switchHandler();
}
/**
* Initialize the application
*/
function initApp() {
// Cache DOM elements
cacheElements();
// Initialize file sharing module first
if (fileSharing?.init) {
fileSharing.init();
} else {
console.warn('fileSharing module not fully initialized');
}
// Then create menus and dialogs after modules have initialized
setTimeout(() => {
ui.initializeContextMenus();
}, 100);
// Setup event listeners
setupEventListeners();
// Initialize favorites module if available
if (favorites?.init) {
console.log('Initializing favorites module');
favorites.init();
} else {
console.warn('Favorites module not available or not initializable');
}
// Initialize recent files module if available
if (recent?.init) {
console.log('Initializing recent files module');
recent.init();
} else {
console.warn('Recent files module not available or not initializable');
}
// Initialize multi-select / batch actions
if (multiSelect?.init) {
console.log('Initializing multi-select module');
multiSelect.init();
}
window.addEventListener('authenticationDone', async () => {
// Check if a context was provided in the URL
const hashContext = deserializeHash();
switchSectionTo(hashContext.section);
if (hashContext.section === 'files') {
if (hashContext.path) {
console.log(`init: reusing folder from hash URL: ${hashContext.path}`);
app.currentPath = hashContext.path;
}
if (hashContext.file !== null) {
app.viewFile = hashContext.file;
}
loadFiles();
}
});
// Wait for translations to load before checking authentication
if (i18n?.isLoaded?.()) {
// Translations already loaded, proceed with authentication
checkAuthentication();
} else {
// Wait for translations to be loaded before proceeding
console.log('Waiting for translations to load...');
window.addEventListener('translationsLoaded', () => {
console.log('Translations loaded, proceeding with authentication');
checkAuthentication();
});
// Set a timeout as a fallback in case translations take too long
setTimeout(() => {
if (!i18n?.isLoaded?.()) {
console.warn('Translations loading timeout, proceeding with authentication anyway');
checkAuthentication();
}
}, 3000); // 3 second timeout
}
}
/**
* Cache DOM elements for faster access
*/
function cacheElements() {
elements.uploadBtn = document.getElementById('upload-btn');
elements.dropzone = document.getElementById('dropzone');
elements.fileInput = document.getElementById('file-input');
elements.filesList = document.getElementById('files-list');
elements.newFolderBtn = document.getElementById('new-folder-btn');
elements.gridViewBtn = document.getElementById('grid-view-btn');
elements.listViewBtn = document.getElementById('list-view-btn');
elements.breadcrumb = document.querySelector('.breadcrumb');
elements.pageTitle = document.querySelector('.page-title');
elements.actionsBar = document.getElementById('actions-bar');
elements.navItems = document.querySelectorAll('.nav-item');
elements.searchInput = document.querySelector('.search-container input');
}
/**
* Setup the upload dropdown button and menu
* Handles opening/closing the dropdown and triggering file/folder inputs
*/
function setupUploadDropdown() {
const uploadBtn = document.getElementById('upload-btn');
const menu = document.getElementById('upload-dropdown-menu');
if (!uploadBtn || !menu) return;
// Abort any previous local bindings (safe across repeated/rebuilt UI)
if (uploadDropdownBindingsController) {
uploadDropdownBindingsController.abort();
}
uploadDropdownBindingsController = new AbortController();
const signal = uploadDropdownBindingsController.signal;
// Toggle dropdown on button click
uploadBtn.addEventListener(
'click',
(e) => {
e.stopPropagation();
const isOpen = menu.classList.contains('show');
// Close any other open dropdowns
document.querySelectorAll('.upload-dropdown-menu.show').forEach((m) => {
m.classList.remove('show');
});
if (!isOpen) {
menu.classList.add('show');
}
},
{ signal }
);
// Close dropdown when clicking outside
// remove+add stable handler: guarantees exactly one global listener
if (uploadDropdownDocumentClickHandler) {
document.removeEventListener('click', uploadDropdownDocumentClickHandler);
}
uploadDropdownDocumentClickHandler = (e) => {
if (e.target.closest('#upload-dropdown')) return;
document.querySelectorAll('.upload-dropdown-menu.show').forEach((m) => {
m.classList.remove('show');
});
};
document.addEventListener('click', uploadDropdownDocumentClickHandler);
}
/**
* Setup event listeners for main UI elements
*/
function setupEventListeners() {
// Set up drag and drop
ui.setupDragAndDrop();
// Debounce timer for live search
let searchDebounceTimer = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 3;
// handle history / url change
window.addEventListener('popstate', (e) => {
if (e.state === null) {
// change is from user (url explicitely change, read information from hash)
const hashContext = deserializeHash();
switchSectionTo(hashContext.section);
if (hashContext.path) {
app.currentPath = hashContext.path;
loadFiles({ insertHistory: false });
}
} else {
// change is from history, data provided in event
switchSectionTo(e.state.section);
app.currentPath = e.state.id;
loadFiles({ insertHistory: false });
}
});
// Search input — Enter key
elements.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
// Cancel any pending debounce
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim();
// In shared section, filter locally
if (app.currentSection === 'shared' && sharedView) {
sharedView.filterAndSortItems();
return;
}
if (query) {
performSearch(query);
} else if (app.isSearchMode) {
// If search is empty and we're in search mode, return to normal view
app.isSearchMode = false;
app.currentPath = '';
ui.updateBreadcrumb('');
loadFiles();
}
}
});
// Search input — Live search (debounced, after 3+ chars)
elements.searchInput.addEventListener('input', () => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim();
if (query.length >= SEARCH_MIN_CHARS) {
searchDebounceTimer = setTimeout(() => {
performSearch(query);
}, SEARCH_DEBOUNCE_MS);
} else if (query.length === 0 && app.isSearchMode) {
// User cleared the search input — return to normal view
searchDebounceTimer = setTimeout(() => {
app.isSearchMode = false;
app.currentPath = '';
ui.updateBreadcrumb('');
loadFiles();
}, SEARCH_DEBOUNCE_MS);
}
});
// Search button
document.getElementById('search-button').addEventListener('click', () => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
const query = elements.searchInput.value.trim();
if (query) {
performSearch(query);
}
});
// Upload dropdown
setupUploadDropdown();
setupActionsBarDelegation();
if (elements.actionsBar) {
elements.actionsBar.dataset.mode = 'files';
}
// File input
elements.fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
fileOps.uploadFiles(e.target.files);
e.target.value = ''; // reset so same file can be re-uploaded
}
});
// Folder input
const folderInput = document.getElementById('folder-input');
if (folderInput) {
folderInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
fileOps.uploadFolderFiles(e.target.files);
e.target.value = '';
}
});
}
// Sidebar navigation
elements.navItems.forEach((item) => {
item.addEventListener('click', () => {
// Remove active class from all nav items
elements.navItems.forEach((navItem) => {
navItem.classList.remove('active');
});
// Add active class to clicked item
item.classList.add('active');
let _updateHistory = true;
const itemI18nKey = item.querySelector('span').getAttribute('data-i18n');
switch (itemI18nKey) {
case 'nav.shared':
// Switch to shared view
switchToSharedSection();
break;
case 'nav.favorites':
// Switch to favorites view
switchToFavoritesSection();
break;
case 'nav.recent':
// Switch to recent files view
switchToRecentFilesSection();
break;
case 'nav.photos':
switchToPhotosSection();
break;
case 'nav.music':
switchToMusicSection();
break;
case 'nav.trash':
switchToTrashSection();
break;
default:
// Use the proper switchToFilesView function which handles all UI restoration
switchToFilesSection();
// FIXME: because fileview handles it: need to converge code
_updateHistory = false;
}
document.title = `OxiCloud: ${i18n.t(itemI18nKey)}`;
if (_updateHistory) {
updateHistory(true);
}
});
});
// Load saved view preference
const savedView = localStorage.getItem('oxicloud-view');
if (savedView === 'list') {
ui.switchToListView();
} else {
ui.switchToGridView();
}
// User menu
setupUserMenu();
// Global events to close context menus and deselect cards
document.addEventListener('click', (e) => {
const folderMenu = document.getElementById('folder-context-menu');
const fileMenu = document.getElementById('file-context-menu');
if (folderMenu && folderMenu.style.display === 'block' && !folderMenu.contains(e.target)) {
ui.closeContextMenu();
}
if (fileMenu && fileMenu.style.display === 'block' && !fileMenu.contains(e.target)) {
ui.closeFileContextMenu();
}
});
}
// View-switching actions moved to app/navigation.js
/**
* Navigate into a folder and refresh the file list.
* @param {string} id
* @param {string} name
*/
export function selectFolder(id, name) {
app.breadcrumbPath.push({ id, name });
app.currentPath = id;
ui.updateBreadcrumb();
loadFiles();
}
/**
* Update the storage usage display with the user's actual storage usage
* @param {Object} userData - The user data object
*/
function updateStorageUsageDisplay(userData) {
// Default values
const DEFAULT_QUOTA = 10 * 1024 * 1024 * 1024; // 10 GB
let usedBytes = 0;
let quotaBytes = DEFAULT_QUOTA;
let usagePercentage = 0;
// Get values from user data if available
if (userData) {
usedBytes = userData.storage_used_bytes || 0;
// Use == null to allow 0 (unlimited) to pass through; only default to DEFAULT_QUOTA when null/undefined
quotaBytes = userData.storage_quota_bytes == null ? DEFAULT_QUOTA : userData.storage_quota_bytes;
// Calculate percentage (avoid division by zero)
if (quotaBytes > 0) {
usagePercentage = Math.min(Math.round((usedBytes / quotaBytes) * 100), 100);
}
}
// Format the numbers for display
const usedFormatted = formatFileSize(usedBytes);
const quotaFormatted = formatQuotaSize(quotaBytes);
// Update the storage display elements
const storageFill = document.querySelector('.storage-fill');
const storageInfo = document.querySelector('.storage-info');
if (storageFill) {
storageFill.style.width = `${usagePercentage}%`;
}
if (storageInfo) {
// Remove data-i18n attribute to prevent i18n from overwriting our value
storageInfo.removeAttribute('data-i18n');
// Use i18n if available
if (i18n?.t) {
storageInfo.textContent = i18n.t('storage.used', {
percentage: usagePercentage,
used: usedFormatted,
total: quotaFormatted
});
} else {
storageInfo.textContent = `${usagePercentage}% used (${usedFormatted} / ${quotaFormatted})`;
}
}
console.log(`Updated storage display: ${usagePercentage}% (${usedFormatted} / ${quotaFormatted})`);
}
export { deserializeHash, initApp, setActionsBarMode, updateHistory, updateStorageUsageDisplay };