feat(navigation) add browser history: permits url bookmarking on current folder
This commit is contained in:
@@ -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');
|
||||
|
||||
+121
-2
@@ -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<FolderInfo>}
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -30,6 +137,14 @@ async function loadFiles(options = {}) {
|
||||
}
|
||||
|
||||
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 +163,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 +178,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');
|
||||
}
|
||||
|
||||
+202
-47
@@ -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:
|
||||
*
|
||||
* #/<section>/
|
||||
*
|
||||
* #/shared
|
||||
* #/recent
|
||||
* ...
|
||||
*
|
||||
* special case of drive:
|
||||
*
|
||||
* #/files/folder/<folder ID>
|
||||
*
|
||||
* @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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ window.app = {
|
||||
currentView: 'grid',
|
||||
currentPath: '',
|
||||
currentFolder: null,
|
||||
currentFolderInfo: null,
|
||||
contextMenuTargetFolder: null,
|
||||
contextMenuTargetFile: null,
|
||||
selectedTargetFolderId: '',
|
||||
|
||||
Reference in New Issue
Block a user