diff --git a/static/js/app/authSession.js b/static/js/app/authSession.js index 8f3f3979..ddbbb6b2 100644 --- a/static/js/app/authSession.js +++ b/static/js/app/authSession.js @@ -119,9 +119,8 @@ async function checkAuthentication() { return; } } - await resolveHomeFolder(); - window.loadFiles(); + window.dispatchEvent(new Event('authenticationDone')); } else { // No cached user data — must verify session from server console.log('No cached user data, fetching from server'); diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 162bcbfe..882cdd47 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -1,8 +1,115 @@ +// @ts-check + +// TODO move to features/files/fileOperations.js ? /** - * Files view loading logic + * @typedef {Object} FolderInfo + * @property {string} category + * @property {number} created_at - timestamp + * @property {string} icon_class + * @property {string} icon_special_class + * @property {string} id the uniq id of the folder + * @property {boolean} is_root + * @property {number} modified_at + * @property {string} name + * @property {string} owner_id + * @property {string|null} parent_id the folder parent (null if is_root) + * @property {string} path the full path */ -async function loadFiles(options = {}) { +/** + * getFolder information + * @param {string} id the id of the folder + * @returns {Promise} + */ +async function getFolder( id) { + + /** @type {HeadersInit} */ + const headers = { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache' + }; + + /** @type {RequestInit} */ + const requestOptions = { + headers, + credentials: 'same-origin', + cache: 'no-store' + }; + + let folderInformations = await fetch( `/api/folders/${id}`, requestOptions); + if (folderInformations.ok) { + return folderInformations.json() + } + else { + console.warn(`Error fetching folder ${id}`); + return Promise.reject(null); + } +} + +/** + * rebuild breadcrumb from selected folder (iterate up to root) + */ +async function rebuildBreadCrumb() { + + const app = window.app; + + /** + * Store the leaf (this is the current displayed folder) + * @type {FolderInfo | null} + */ + let currentFolderInfo = null; + + // rebuild full breadcrumb, + // TODO: to optimize, data may already be known / or ETAG could be interesting to reduce load + app.breadcrumbPath = []; + + /** @type {string | null} */ + let id = app.currentPath; + + // recurse from selected folder to root + while (id !== null) { + console.log(`fetching folder information for folder ${id}`); + try { + let folderInfo = await getFolder(id); + + // store the Leaf which is the current folder + if (currentFolderInfo === null) { + currentFolderInfo = folderInfo; + } + + // XXX do not enter root into bread crumb updateBreadcrumb() method always display it + if(!folderInfo.is_root) { + app.breadcrumbPath.unshift( {id: folderInfo.id, name: folderInfo.name}); + } + + // iterate to parent folder + id = folderInfo.parent_id; + } + catch( e) { + console.log(`Error loading information from folder ${app.currentPath}, falling back to ${app.userHomeFolderId}`); + // fallback of root + window.uiNotifications.show( + 'error: folder not found or permission denied', + 'the given folder is not available or you do not have sufficient rights' + ); + app.breadcrumbPath = []; + id = app.userHomeFolderId; + app.currentPath = id; + } + } + + // store informations on the current folder + app.currentFolderInfo = currentFolderInfo; +} + +/** + * Files view loading logic + * + * @param {Object} options + * @param {boolean} [options.insertHistory] add browser history (default true) + * @param {boolean} [options.forceRefresh] force refresh of content + */ +async function loadFiles(options = { insertHistory: true}) { const app = window.app; const elements = window.appElements; @@ -18,18 +125,30 @@ async function loadFiles(options = {}) { window.isLoadingFiles = true; - elements.filesGrid.innerHTML = ` -
-
- ${window.i18n ? window.i18n.t('files.loading') : 'Loading files…'} -
- `; + // This to avoid blinking page, a better solution would be to put loading on an overlay and remove timeout + let loadingFiles = setTimeout( () => { + // display loader after few delay (will be canceled if result take less time) + elements.filesGrid.innerHTML = ` +
+
+ ${window.i18n ? window.i18n.t('files.loading') : 'Loading files…'} +
+ `; + }, 100); if (!app.userHomeFolderId) { await window.resolveHomeFolder(); } const timestamp = new Date().getTime(); + + await rebuildBreadCrumb(); + + // request a breadcrumb paint + window.ui.updateBreadcrumb(); + + window.updateHistory( options.insertHistory || false); + let url; if (!app.currentPath || app.currentPath === '') { @@ -48,10 +167,13 @@ async function loadFiles(options = {}) { console.log(`Loading subfolder content: ${app.currentPath}`); } + /** @type {HeadersInit} */ const headers = { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }; + + /** @type {RequestInit} */ const requestOptions = { headers, credentials: 'same-origin', @@ -60,6 +182,7 @@ async function loadFiles(options = {}) { if (forceRefresh) { url += `&force_refresh=true`; + // @ts-ignore requestOptions.headers['X-Force-Refresh'] = 'true'; console.log('Forcing complete refresh ignoring cache'); } @@ -67,6 +190,9 @@ async function loadFiles(options = {}) { console.log(`Loading listing from ${url}`); const response = await fetch(url, requestOptions); + // not required anymore + clearTimeout( loadingFiles); + if (response.status === 401 || response.status === 403) { console.warn("Auth error when loading files, showing empty list"); elements.filesGrid.innerHTML = '

Could not load files

'; diff --git a/static/js/app/main.js b/static/js/app/main.js index bc7a984d..55cef862 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -3,11 +3,16 @@ * This file contains the core functionality, initialization and state management */ +// @ts-check + const app = window.app; const elements = window.appElements; // Upload dropdown listener state (prevents accumulated listeners) +/** @type { function | null } */ let uploadDropdownDocumentClickHandler = null; + +/** @type { AbortController | null } */ let uploadDropdownBindingsController = null; let actionsBarDelegationBound = false; @@ -90,6 +95,12 @@ const ACTIONS_BAR_TEMPLATES = { ` }; +/** + * + * @param {string} mode + * @param {boolean} [force=false] + * @returns + */ function setActionsBarMode(mode, force = false) { if (!elements.actionsBar) return; @@ -180,6 +191,126 @@ function setupActionsBarDelegation() { }); } +/** + * @typedef {Object} OxiContext + * @property {string | null} path the uuid of the path + * @property {string} section + */ + +/** + * Read the application hash + * + * format: + * + * #/
/ + * + * #/shared + * #/recent + * ... + * + * special case of drive: + * + * #/files/folder/ + * + * @returns {OxiContext} + */ +function deserializeHash() { + let hashContext = /** type {OxiContext} */ {}; + + // FIXME rename files into drive ? + hashContext.section = 'files'; + + let hash_elements = window.location.hash.split("/"); + + let section = hash_elements[1]; + + // FIXME: use navigation.VIEW_FLAGS + if (section && ['files', 'shared', 'recent', 'favorites', 'trash', 'photos'].includes(section)) { + hashContext.section = section; + } + + if (hash_elements[1] == 'files' && hash_elements[2] == 'folder' && hash_elements[3] !== null) { + hashContext.path = hash_elements[3]; + } + + 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 app = window.app; + + let historyData = { + section: app.currentSection, + id: app.currentFolder, + }; + let historyUrl = `#/${app.currentSection}`; + + if (app.currentSection === 'files' && app.currentFolderInfo !== null) { + historyData.id = app.currentFolder; + historyUrl = historyUrl.concat('/', app.currentFolderInfo.id); + + // 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 (window.app.currentSection === section) + // no change ... + return; + + switch (section) { + case "files": + switchToFilesView(); + + case "shared": + switchToSharedView(); + break; + + case "recent": + switchToRecentFilesView(); + break; + + case "favorites": + switchToFavoritesView(); + break; + + case "photos": + switchToPhotosView(); + break; + + case "trash": + switchToTrashView(); + break; + + default: + console.warn(`context view ${section} unkonwn fallback to drive section`); + switchToFilesView(); + } +} + /** * Initialize the application */ @@ -233,6 +364,20 @@ function initApp() { window.multiSelect.init(); } + window.addEventListener('authenticationDone', () => { + // Check if a context was provided in the URL + let hashContext = deserializeHash(); + switchSectionTo( hashContext.section); + if (hashContext.section === "files") { + if (hashContext.path) { + console.log(`init: reusing folder from hash URL: ${hashContext.path}`); + window.app.currentPath = hashContext.path; + } + window.loadFiles(); + } + + }); + // Wait for translations to load before checking authentication if (window.i18n && window.i18n.isLoaded && window.i18n.isLoaded()) { // Translations already loaded, proceed with authentication @@ -271,7 +416,6 @@ function cacheElements() { elements.pageTitle = document.querySelector('.page-title'); elements.actionsBar = document.querySelector('.actions-bar'); elements.navItems = document.querySelectorAll('.nav-item'); - elements.trashBtn = document.querySelector('.nav-item:nth-child(6)'); // The trash nav item (after Photos) elements.searchInput = document.querySelector('.search-container input'); } @@ -306,12 +450,14 @@ function setupUploadDropdown() { // Close dropdown when clicking outside // remove+add stable handler: guarantees exactly one global listener if (uploadDropdownDocumentClickHandler) { + // @ts-ignore 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')); }; + // @ts-ignore document.addEventListener('click', uploadDropdownDocumentClickHandler); } @@ -327,6 +473,25 @@ function setupEventListeners() { 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) + let hashContext = deserializeHash(); + switchSectionTo( hashContext.section); + if (hashContext.path) { + window.app.currentPath = hashContext.path; + window.loadFiles({insertHistory: false}); + } + } + else { + // change is from history, data provided in event + switchSectionTo( e.state.section); + window.app.currentPath = e.state.id; + window.loadFiles({insertHistory: false}); + } + }); + // Search input — Enter key elements.searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { @@ -415,55 +580,44 @@ function setupEventListeners() { // Add active class to clicked item item.classList.add('active'); - - // Check if this is the shared item - if (item.querySelector('span').getAttribute('data-i18n') === 'nav.shared') { - // Switch to shared view - switchToSharedView(); - return; - } - - // Check if this is the favorites item - if (item.querySelector('span').getAttribute('data-i18n') === 'nav.favorites') { - // Switch to favorites view - switchToFavoritesView(); - return; - } - - // Check if this is the recent files item - if (item.querySelector('span').getAttribute('data-i18n') === 'nav.recent') { - // Switch to recent files view - switchToRecentFilesView(); - return; + let _updateHistory = true; + + let itemI18nKey=item.querySelector('span').getAttribute('data-i18n') + switch(itemI18nKey) { + case 'nav.shared': + // Switch to shared view + switchToSharedView(); + break; + + case 'nav.favorites': + // Switch to favorites view + switchToFavoritesView(); + break; + + case 'nav.recent': + // Switch to recent files view + switchToRecentFilesView(); + break; + + case 'nav.photos': + switchToPhotosView(); + break; + + case 'nav.trash': + switchToTrashView(); + break; + + default: + // Use the proper switchToFilesView function which handles all UI restoration + window.switchToFilesView(); + // FIXME: because fileview handles it: need to converge code + _updateHistory = false; } - // Check if this is the photos item - if (item.querySelector('span').getAttribute('data-i18n') === 'nav.photos') { - switchToPhotosView(); - return; - } + document.title = `OxiCloud: ${window.i18n.t(itemI18nKey)}`; - // Check if this is the trash item - if (item === elements.trashBtn) { - setCurrentSection('trash'); - - // Hide breadcrumb (only shown in Files view) - const breadcrumb = document.querySelector('.breadcrumb'); - if (breadcrumb) breadcrumb.style.display = 'none'; - - // Show files containers (to be filled with trash) - const filesGrid = document.getElementById('files-grid'); - const filesListView = document.getElementById('files-list-view'); - if (filesGrid) { filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; filesGrid.classList.toggle('hidden', app.currentView !== 'grid'); } - if (filesListView) { filesListView.style.display = app.currentView === 'list' ? 'flex' : 'none'; filesListView.classList.toggle('hidden', app.currentView !== 'list'); } - - setActionsBarMode('trash'); - - // Load trash items - window.loadTrashItems(); - } else { - // Use the proper switchToFilesView function which handles all UI restoration - window.switchToFilesView(); + if (_updateHistory) { + updateHistory( true); } }); }); @@ -577,3 +731,4 @@ window.updateStorageUsageDisplay = updateStorageUsageDisplay; // Initialize app when DOM is ready window.initApp = initApp; +window.updateHistory = updateHistory; diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index af50eeb4..0121d037 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -287,8 +287,28 @@ function switchToPhotosView() { } } +function switchToTrashView() { + setCurrentSection('trash'); + + // Hide breadcrumb (only shown in Files view) + const breadcrumb = document.querySelector('.breadcrumb'); + if (breadcrumb) breadcrumb.style.display = 'none'; + + // Show files containers (to be filled with trash) + const filesGrid = document.getElementById('files-grid'); + const filesListView = document.getElementById('files-list-view'); + if (filesGrid) { filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; filesGrid.classList.toggle('hidden', app.currentView !== 'grid'); } + if (filesListView) { filesListView.style.display = app.currentView === 'list' ? 'flex' : 'none'; filesListView.classList.toggle('hidden', app.currentView !== 'list'); } + + setActionsBarMode('trash'); + + // Load trash items + window.loadTrashItems(); +} + window.switchToFilesView = switchToFilesView; window.switchToSharedView = switchToSharedView; window.switchToFavoritesView = switchToFavoritesView; window.switchToRecentFilesView = switchToRecentFilesView; window.switchToPhotosView = switchToPhotosView; +window.switchToTrashView = switchToTrashView; diff --git a/static/js/app/state.js b/static/js/app/state.js index 2d3eb329..696293e3 100644 --- a/static/js/app/state.js +++ b/static/js/app/state.js @@ -7,6 +7,7 @@ window.app = { currentView: 'grid', currentPath: '', currentFolder: null, + currentFolderInfo: null, contextMenuTargetFolder: null, contextMenuTargetFile: null, selectedTargetFolderId: '',