feat(navigation) add browser history: permits url bookmarking on current folder

This commit is contained in:
Edouard Vanbelle
2026-03-22 22:55:42 +01:00
parent 128dd8a76a
commit fdf1184601
5 changed files with 345 additions and 51 deletions
+1 -2
View File
@@ -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
View File
@@ -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');
}
+201 -46
View File
@@ -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');
let _updateHistory = true;
// Check if this is the shared item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.shared') {
// Switch to shared view
switchToSharedView();
return;
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 favorites item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.favorites') {
// Switch to favorites view
switchToFavoritesView();
return;
}
document.title = `OxiCloud: ${window.i18n.t(itemI18nKey)}`;
// Check if this is the recent files item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.recent') {
// Switch to recent files view
switchToRecentFilesView();
return;
}
// Check if this is the photos item
if (item.querySelector('span').getAttribute('data-i18n') === 'nav.photos') {
switchToPhotosView();
return;
}
// 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;
+20
View File
@@ -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;
+1
View File
@@ -7,6 +7,7 @@ window.app = {
currentView: 'grid',
currentPath: '',
currentFolder: null,
currentFolderInfo: null,
contextMenuTargetFolder: null,
contextMenuTargetFile: null,
selectedTargetFolderId: '',