/** * OxiCloud - Main Application * This file contains the core functionality, initialization and state management */ /** * Escape HTML special characters to prevent XSS attacks. * Use this whenever inserting user-provided text (file names, folder names, etc.) into HTML. * @param {string} str - The string to escape * @returns {string} The escaped string safe for HTML insertion */ function escapeHtml(str) { if (typeof str !== 'string') return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } window.escapeHtml = escapeHtml; // Global state const app = { currentView: 'grid', // Current view mode: 'grid' or 'list' currentPath: '', // Current folder path currentFolder: null, // Current folder object contextMenuTargetFolder: null, // Target folder for context menu contextMenuTargetFile: null, // Target file for context menu selectedTargetFolderId: "", // Selected target folder for move operations moveDialogMode: 'file', // Move dialog mode: 'file' or 'folder' isTrashView: false, // Whether we're in trash view isSharedView: false, // Whether we're in shared view isFavoritesView: false, // Whether we're in favorites view isRecentView: false, // Whether we're in recent files view currentSection: 'files', // Current section: 'files', 'trash', 'shared', 'favorites' or 'recent' isSearchMode: false, // Whether we're in search mode // File sharing related properties shareDialogItem: null, // Item being shared in share dialog shareDialogItemType: null, // Type of item being shared ('file' or 'folder') notificationShareUrl: null // URL for notification dialog }; // DOM elements const elements = { // Will be populated on initialization }; /** * Initialize the application */ function initApp() { // Cache DOM elements cacheElements(); // Initialize file sharing module first if (window.fileSharing && window.fileSharing.init) { window.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 file renderer if available if (window.fileRenderer) { console.log('Using optimized file renderer'); } else { console.log('Using standard file rendering'); } // Check if inline viewer is initialized if (window.inlineViewer) { console.log('Inline viewer is available'); } else { console.warn('Inline viewer not initialized yet, will initialize it now'); try { // Create inline viewer if not already created and if the class exists if (typeof InlineViewer !== 'undefined') { window.inlineViewer = new InlineViewer(); } else { console.warn('InlineViewer class is not defined, skipping initialization'); } } catch (e) { console.error('Error initializing inline viewer:', e); } } // Initialize favorites module if available if (window.favorites && window.favorites.init) { console.log('Initializing favorites module'); window.favorites.init(); } else { console.warn('Favorites module not available or not initializable'); } // Initialize recent files module if available if (window.recent && window.recent.init) { console.log('Initializing recent files module'); window.recent.init(); } else { console.warn('Recent files module not available or not initializable'); } // Initialize multi-select / batch actions if (window.multiSelect && window.multiSelect.init) { console.log('Initializing multi-select module'); window.multiSelect.init(); } // Wait for translations to load before checking authentication if (window.i18n && window.i18n.isLoaded && window.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 (!window.i18n || !window.i18n.isLoaded || !window.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.filesGrid = document.getElementById('files-grid'); elements.filesListView = document.getElementById('files-list-view'); 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.querySelector('.actions-bar'); elements.navItems = document.querySelectorAll('.nav-item'); elements.trashBtn = document.querySelector('.nav-item:nth-child(5)'); // The trash nav item elements.searchInput = document.querySelector('.search-container input'); } /** * Setup the user menu (avatar dropdown with profile, storage, theme, about, logout) */ function setupUserMenu() { const wrapper = document.getElementById('user-menu-wrapper'); const avatarBtn = document.getElementById('user-avatar-btn'); const menu = document.getElementById('user-menu'); const logoutBtn = document.getElementById('user-menu-logout'); const themeBtn = document.getElementById('user-menu-theme'); const aboutBtn = document.getElementById('user-menu-about'); const adminBtn = document.getElementById('user-menu-admin'); const adminDivider = document.getElementById('user-menu-admin-divider'); const profileBtn = document.getElementById('user-menu-profile'); const roleBadge = document.getElementById('user-menu-role-badge'); if (!wrapper || !avatarBtn || !menu) return; // Toggle menu avatarBtn.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = wrapper.classList.contains('open'); wrapper.classList.toggle('open'); // Close notification bell if open const notifWrapper = document.getElementById('notif-wrapper'); const notifBtn = document.getElementById('notif-bell-btn'); if (notifWrapper) notifWrapper.classList.remove('open'); if (notifBtn) notifBtn.classList.remove('active'); if (!isOpen) { updateUserMenuData(); // Show/hide admin panel button based on user role const USER_DATA_KEY = 'oxicloud_user'; const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); const isAdmin = userData.role === 'admin'; if (adminBtn) adminBtn.style.display = isAdmin ? 'flex' : 'none'; if (adminDivider) adminDivider.style.display = isAdmin ? 'block' : 'none'; if (roleBadge) roleBadge.style.display = isAdmin ? 'block' : 'none'; } }); // Close menu on outside click document.addEventListener('click', (e) => { if (wrapper.classList.contains('open') && !wrapper.contains(e.target)) { wrapper.classList.remove('open'); } }); // Logout if (logoutBtn) { logoutBtn.addEventListener('click', () => { wrapper.classList.remove('open'); logout(); }); } // Theme toggle (dark mode) if (themeBtn) { const pill = document.getElementById('theme-toggle-pill'); const isDark = localStorage.getItem('oxicloud_theme') === 'dark'; if (isDark) { if (pill) pill.classList.add('active'); document.documentElement.setAttribute('data-theme', 'dark'); } themeBtn.addEventListener('click', (e) => { e.stopPropagation(); if (pill) { pill.classList.toggle('active'); const dark = pill.classList.contains('active'); localStorage.setItem('oxicloud_theme', dark ? 'dark' : 'light'); document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); window.ui.showNotification( dark ? 'πŸŒ™' : 'β˜€οΈ', dark ? 'Dark mode enabled' : 'Light mode enabled' ); } }); } // Admin panel link if (adminBtn) { adminBtn.addEventListener('click', () => { wrapper.classList.remove('open'); window.location.href = '/admin'; }); } // Profile button β€” navigates to profile page if (profileBtn) { profileBtn.addEventListener('click', () => { wrapper.classList.remove('open'); window.location.href = '/profile'; }); } // About modal if (aboutBtn) { aboutBtn.addEventListener('click', () => { wrapper.classList.remove('open'); const overlay = document.getElementById('about-modal-overlay'); if (overlay) overlay.classList.add('show'); }); } // About modal close const aboutCloseBtn = document.getElementById('about-close-btn'); const aboutOverlay = document.getElementById('about-modal-overlay'); if (aboutCloseBtn) { aboutCloseBtn.addEventListener('click', () => { aboutOverlay.classList.remove('show'); }); } if (aboutOverlay) { aboutOverlay.addEventListener('click', (e) => { if (e.target === aboutOverlay) { aboutOverlay.classList.remove('show'); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && aboutOverlay.classList.contains('show')) { aboutOverlay.classList.remove('show'); } }); } // Fetch version from backend (centralized in Cargo.toml) fetchAppVersion(); } /** * Update user menu data (name, email, storage) from localStorage */ function updateUserMenuData() { const USER_DATA_KEY = 'oxicloud_user'; const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); const nameEl = document.getElementById('user-menu-name'); const emailEl = document.getElementById('user-menu-email'); const avatarEl = document.getElementById('user-menu-avatar'); const storageFill = document.getElementById('user-menu-storage-fill'); const storageText = document.getElementById('user-menu-storage-text'); if (userData.username) { if (nameEl) nameEl.textContent = userData.username; if (emailEl) emailEl.textContent = userData.email || ''; if (avatarEl) avatarEl.textContent = userData.username.substring(0, 2).toUpperCase(); } // Storage info const usedBytes = userData.storage_used_bytes || 0; const quotaBytes = userData.storage_quota_bytes || 10737418240; const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0; if (storageFill) storageFill.style.width = percentage + '%'; if (storageText) { const used = formatFileSize(usedBytes); const total = formatFileSize(quotaBytes); storageText.textContent = `${percentage}% Β· ${used} / ${total}`; } } /** * Fetch app version from backend (centralized in Cargo.toml) * Updates the about modal version display */ async function fetchAppVersion() { try { const response = await fetch('/api/version'); if (response.ok) { const data = await response.json(); const versionEl = document.getElementById('about-version'); if (versionEl && data.version) { versionEl.textContent = `v${data.version}`; } } } catch (err) { console.warn('Could not fetch app version:', err); // Fallback: leave placeholder } } /** * Setup the upload dropdown button and menu * Handles opening/closing the dropdown and triggering file/folder inputs */ function setupUploadDropdown() { const dropdown = document.getElementById('upload-dropdown'); const uploadBtn = document.getElementById('upload-btn'); const menu = document.getElementById('upload-dropdown-menu'); const uploadFilesBtn = document.getElementById('upload-files-btn'); const uploadFolderBtn = document.getElementById('upload-folder-btn'); if (!uploadBtn || !menu) return; // 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'); } }); // Upload files option if (uploadFilesBtn) { uploadFilesBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.remove('show'); elements.fileInput.click(); }); } // Upload folder option if (uploadFolderBtn) { uploadFolderBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.remove('show'); const folderInput = document.getElementById('folder-input'); if (folderInput) { folderInput.click(); } }); } // Close dropdown when clicking outside document.addEventListener('click', () => { document.querySelectorAll('.upload-dropdown-menu.show').forEach(m => m.classList.remove('show')); }); } /** * 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; // 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(); 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(); // 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 = ''; } }); } // New folder button elements.newFolderBtn.addEventListener('click', async () => { const folderName = await window.Modal.promptNewFolder(); if (folderName) { fileOps.createFolder(folderName); } }); // View toggle elements.gridViewBtn.addEventListener('click', ui.switchToGridView); elements.listViewBtn.addEventListener('click', ui.switchToListView); // 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'); // 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; } // Check if this is the trash item if (item === elements.trashBtn) { // Hide shared view if active if (app.isSharedView) { // Hide shared view if (window.sharedView) { window.sharedView.hide(); } // Reset shared view flag app.isSharedView = false; // Clean up shared containers if they exist const sharedContainer = document.getElementById('shared-container'); if (sharedContainer) { sharedContainer.style.display = 'none'; } } // Show trash view app.isTrashView = true; app.currentSection = 'trash'; // 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'; if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; // Update UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.trash') : 'Trash'; elements.actionsBar.innerHTML = `
`; elements.actionsBar.style.display = 'flex'; // Add event listener to empty trash button document.getElementById('empty-trash-btn').addEventListener('click', async () => { if (await fileOps.emptyTrash()) { loadTrashItems(); } }); // Load trash items loadTrashItems(); } else { // Check if we need to reset shared view if (app.isSharedView) { // Hide shared view if (window.sharedView) { window.sharedView.hide(); } // Reset shared view flag app.isSharedView = false; // Clean up shared containers if they exist const sharedContainer = document.getElementById('shared-container'); if (sharedContainer) { sharedContainer.style.display = 'none'; } } // Show regular files view app.isTrashView = false; app.currentSection = 'files'; // Reset UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.files') : 'Files'; elements.actionsBar.innerHTML = `
`; elements.actionsBar.style.display = 'flex'; // Show files containers const filesGrid = document.getElementById('files-grid'); const filesListView = document.getElementById('files-list-view'); if (filesGrid) filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; // Restore event listeners setupUploadDropdown(); document.getElementById('new-folder-btn').addEventListener('click', async () => { const folderName = await window.Modal.promptNewFolder(); if (folderName) { fileOps.createFolder(folderName); } }); document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); // Restore cached elements 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'); // Load regular files app.currentPath = ''; ui.updateBreadcrumb(''); loadFiles(); } }); }); // Load saved view preference const savedView = localStorage.getItem('oxicloud-view'); if (savedView === 'list') { ui.switchToListView(); } // 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(); } // Deselect all cards when clicking empty area (not on a card, menu, or modal) if (!e.target.closest('.file-card') && !e.target.closest('.file-item') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal') && !e.target.closest('.batch-action-bar')) { document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected')); document.querySelectorAll('.file-item.selected').forEach(c => c.classList.remove('selected')); if (window.multiSelect) window.multiSelect.clear(); } }); } /** * Load files and folders for the current path */ async function loadFiles(options = {}) { try { console.log("Starting loadFiles() - loading files...", options); // Flag to force complete refresh ignoring cache const forceRefresh = options.forceRefresh || false; // Prevent multiple simultaneous load requests if (window.isLoadingFiles) { console.log("A file load is already in progress, ignoring request"); return; } window.isLoadingFiles = true; // Show loading spinner elements.filesGrid.innerHTML = `
${window.i18n ? window.i18n.t('files.loading') : 'Loading files…'}
`; // Always ensure a userHomeFolderId is set if (!app.userHomeFolderId) { await resolveHomeFolder(); } // Add timestamp to avoid cache const timestamp = new Date().getTime(); let url; // ALWAYS use the userHomeFolderId (current folder or home folder) to avoid showing root if (!app.currentPath || app.currentPath === '') { // If at root, force user to their home folder if (app.userHomeFolderId) { url = `/api/folders/${app.userHomeFolderId}/contents?t=${timestamp}`; app.currentPath = app.userHomeFolderId; ui.updateBreadcrumb(app.userHomeFolderName || 'Home'); console.log(`Loading user folder: ${app.userHomeFolderName} (${app.userHomeFolderId})`); } else { // Emergency fallback - this should rarely happen but prevents errors url = `/api/folders?t=${timestamp}`; console.warn("Emergency fallback to root folder - this should not normally happen"); } } else { // Normal case - viewing subfolder contents url = `/api/folders/${app.currentPath}/contents?t=${timestamp}`; console.log(`Loading subfolder content: ${app.currentPath}`); } const token = localStorage.getItem('oxicloud_token'); const headers = { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const requestOptions = { headers, cache: 'no-store' // Instruct the browser not to use cache }; // If forceRefresh is specified, add an additional parameter to avoid cache if (forceRefresh) { url += `&force_refresh=true`; requestOptions.headers['X-Force-Refresh'] = 'true'; console.log('Forcing complete refresh ignoring cache'); } console.log(`Loading files from ${url}`); const response = await fetch(url, requestOptions); // Critical error handling if (response.status === 401 || response.status === 403) { console.warn("Auth error when loading files, showing empty list"); // Just show empty state instead of causing redirect loops elements.filesGrid.innerHTML = '

Could not load files

'; elements.filesListView.innerHTML = `
Name
Type
Size
Modified
`; return; } if (!response.ok) { throw new Error(`Server responded with status: ${response.status}`); } const folders = await response.json(); // Clear existing files in both views if (window.multiSelect) window.multiSelect.clear(); elements.filesGrid.innerHTML = ''; elements.filesListView.innerHTML = `
Name
Type
Size
Modified
`; // Re-wire select-all checkbox after DOM rebuild const selectAllCb = document.getElementById('select-all-checkbox'); if (selectAllCb && window.multiSelect) { selectAllCb.addEventListener('change', () => window.multiSelect.toggleAll()); } // Translate the header if i18n is available if (window.i18n && window.i18n.translatePage) { window.i18n.translatePage(); } // Add folders (check if it's an array) const folderList = Array.isArray(folders) ? folders : []; // Backend already scopes folders to the authenticated user, // so no client-side filtering is needed. folderList.forEach(folder => { ui.addFolderToView(folder); }); // Also load files in this folder const cacheTimestamp = new Date().getTime(); let filesUrl = `/api/files?t=${cacheTimestamp}`; // Add timestamp to avoid cache issues if (app.currentPath) { filesUrl += `&folder_id=${app.currentPath}`; } console.log(`Loading files from: ${filesUrl}`); try { console.log(`Fetching files from: ${filesUrl}`); const filesResponse = await fetch(filesUrl, requestOptions); // Use same auth token console.log(`Files response status: ${filesResponse.status}`); // Handle auth errors for files too if (filesResponse.status === 401 || filesResponse.status === 403) { console.warn("Auth error when loading files"); return; // Already showing folders, just stop here } if (filesResponse.ok) { const files = await filesResponse.json(); console.log(`Files received:`, files); // Add files (check if it's an array) const fileList = Array.isArray(files) ? files : []; console.log(`Processing ${fileList.length} files`); fileList.forEach(file => { console.log(`Adding file to view: ${file.name} (${file.id})`); ui.addFileToView(file); }); } else { const errorText = await filesResponse.text(); console.error(`Error loading files: ${filesResponse.status} - ${errorText}`); } } catch (error) { console.error('Error loading files:', error); // File API may not be implemented yet, so we silently ignore this error } // Update file icons based on file type ui.updateFileIcons(); } catch (error) { console.error('Error loading folders:', error); ui.showNotification('Error', 'Could not load files and folders'); } finally { // Mark that we are no longer loading files to allow future requests window.isLoadingFiles = false; } } /** * Format file size in human-readable format * @param {number} bytes - Size in bytes * @return {string} Formatted size */ function formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Load trash items */ async function loadTrashItems() { try { // Clear existing content if (window.multiSelect) window.multiSelect.clear(); elements.filesGrid.innerHTML = ''; elements.filesListView.innerHTML = `
Name
Type
Original location
Deletion date
Actions
`; // Translate the header if i18n is available if (window.i18n && window.i18n.translatePage) { window.i18n.translatePage(); } // Update breadcrumb - just show Home ui.updateBreadcrumb(''); // Get trash items const trashItems = await fileOps.getTrashItems(); if (trashItems.length === 0) { // Show empty state const emptyState = document.createElement('div'); emptyState.className = 'empty-state'; emptyState.innerHTML = `

${window.i18n ? window.i18n.t('trash.empty_state') : 'The trash is empty'}

`; elements.filesGrid.appendChild(emptyState); return; } // Process each trash item trashItems.forEach(item => { addTrashItemToView(item); }); } catch (error) { console.error('Error loading trash items:', error); window.ui.showNotification('Error', 'Error loading trash items'); } } /** * Add a trash item to the view * @param {Object} item - Trash item object */ function addTrashItemToView(item) { const isFile = item.item_type === 'file'; // Format date - backend sends trashed_at as ISO 8601 string const deletedDate = new Date(item.trashed_at); const formattedDate = deletedDate.toLocaleDateString() + ' ' + deletedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); // Determine type label and icon from extension (trash DTO has no mime_type) let typeLabel; let iconClass; if (!isFile) { iconClass = 'fas fa-folder'; typeLabel = window.i18n ? window.i18n.t('files.file_types.folder') : 'Folder'; } else { const ext = (item.name.split('.').pop() || '').toLowerCase(); const imageExts = ['jpg','jpeg','png','gif','bmp','svg','webp','ico','tiff']; const videoExts = ['mp4','avi','mkv','mov','wmv','flv','webm']; const audioExts = ['mp3','wav','ogg','flac','aac','wma','m4a']; const textExts = ['txt','md','csv','log','ini','cfg','conf']; if (ext === 'pdf') { iconClass = 'fas fa-file-pdf'; typeLabel = window.i18n ? window.i18n.t('files.file_types.pdf') : 'PDF'; } else if (imageExts.includes(ext)) { iconClass = 'fas fa-file-image'; typeLabel = window.i18n ? window.i18n.t('files.file_types.image') : 'Image'; } else if (videoExts.includes(ext)) { iconClass = 'fas fa-file-video'; typeLabel = window.i18n ? window.i18n.t('files.file_types.video') : 'Video'; } else if (audioExts.includes(ext)) { iconClass = 'fas fa-file-audio'; typeLabel = window.i18n ? window.i18n.t('files.file_types.audio') : 'Audio'; } else if (textExts.includes(ext)) { iconClass = 'fas fa-file-alt'; typeLabel = window.i18n ? window.i18n.t('files.file_types.text') : 'Text'; } else { iconClass = 'fas fa-file'; typeLabel = window.i18n ? window.i18n.t('files.file_types.document') : 'Document'; } } // Grid view element const gridElement = document.createElement('div'); gridElement.className = 'file-card trash-item'; gridElement.dataset.trashId = item.id; gridElement.dataset.originalId = item.original_id; gridElement.dataset.itemType = item.item_type; gridElement.innerHTML = `
${escapeHtml(item.name)}
${escapeHtml(typeLabel)} - ${escapeHtml(formattedDate)}
`; // Add action buttons event listeners gridElement.querySelector('.btn-restore').addEventListener('click', async (e) => { e.stopPropagation(); if (await fileOps.restoreFromTrash(item.id)) { loadTrashItems(); } }); gridElement.querySelector('.btn-delete').addEventListener('click', async (e) => { e.stopPropagation(); if (await fileOps.deletePermanently(item.id)) { loadTrashItems(); } }); elements.filesGrid.appendChild(gridElement); // List view element const listElement = document.createElement('div'); listElement.className = 'file-item trash-item'; listElement.dataset.trashId = item.id; listElement.dataset.originalId = item.original_id; listElement.dataset.itemType = item.item_type; listElement.innerHTML = `
${escapeHtml(item.name)}
${escapeHtml(typeLabel)}
${escapeHtml(item.original_path || '--')}
${escapeHtml(formattedDate)}
`; // Add action buttons event listeners for list view listElement.querySelector('.btn-restore').addEventListener('click', async (e) => { e.stopPropagation(); if (await fileOps.restoreFromTrash(item.id)) { loadTrashItems(); } }); listElement.querySelector('.btn-delete').addEventListener('click', async (e) => { e.stopPropagation(); if (await fileOps.deletePermanently(item.id)) { loadTrashItems(); } }); elements.filesListView.appendChild(listElement); } /** * Perform search with the given query. * All processing (filtering, scoring, sorting, categorization) is done * server-side in Rust. This function only sends the request and renders. * * @param {string} query - Search query * @param {string} [sortBy] - Sort order (relevance|name|name_desc|date|date_desc|size|size_desc) */ async function performSearch(query, sortBy) { console.log(`Performing search for: "${query}" (sort: ${sortBy || 'relevance'})`); try { app.isSearchMode = true; ui.updateBreadcrumb(`Search: "${query}"`); // Show loading spinner const filesGrid = document.getElementById('files-grid'); if (filesGrid) { filesGrid.innerHTML = `

Searching for "${query}"...

`; } // All options β€” backend handles all processing const options = { recursive: true, limit: 100, sort_by: sortBy || 'relevance' }; // Restrict search to user's folder context if (!app.isTrashView) { options.folder_id = app.currentPath; if (!options.folder_id || options.folder_id === '') { await resolveHomeFolder(); options.folder_id = app.currentPath; } } // Send search request β€” backend does all processing const searchResults = await window.search.searchFiles(query, options); // Render enriched results from the server window.search.displaySearchResults(searchResults); } catch (error) { console.error('Search error:', error); window.ui.showNotification('Error', 'Error performing search'); } } // Listen for re-sort events from the search sort dropdown document.addEventListener('search-resort', (e) => { const searchInput = document.querySelector('.search-container input'); if (searchInput && searchInput.value.trim()) { performSearch(searchInput.value.trim(), e.detail.sort_by); } }); // Expose needed functions to global scope window.app = app; window.loadFiles = loadFiles; window.loadTrashItems = loadTrashItems; window.formatFileSize = formatFileSize; window.performSearch = performSearch; // Set up global selectFolder function for navigation window.selectFolder = (id, name) => { app.currentPath = id; ui.updateBreadcrumb(name); loadFiles(); }; /** * Switch to the shared view */ function switchToSharedView() { // Hide trash view if active app.isTrashView = false; // Set shared view as active app.isSharedView = true; app.currentSection = 'shared'; // Remove active class from all nav items elements.navItems.forEach(navItem => navItem.classList.remove('active')); // Find shared nav item and make it active const sharedNavItem = document.querySelector('.nav-item:nth-child(2)'); if (sharedNavItem) { sharedNavItem.classList.add('active'); } // Update UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.shared') : 'Shared'; // Clear breadcrumb and show root ui.updateBreadcrumb(''); // Hide standard actions bar if (elements.actionsBar) { elements.actionsBar.style.display = 'none'; } // Init and show shared view if (window.sharedView) { window.sharedView.init(); window.sharedView.show(); } } /** * Switch back to the files view */ function switchToFilesView() { // Reset view flags app.isTrashView = false; app.isSharedView = false; app.isFavoritesView = false; app.isRecentView = false; app.currentSection = 'files'; // Update UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.files') : 'Files'; // Remove active class from all nav items elements.navItems.forEach(navItem => navItem.classList.remove('active')); // Make files nav item active const filesNavItem = document.querySelector('.nav-item:first-child'); if (filesNavItem) { filesNavItem.classList.add('active'); } // Reset UI elements.actionsBar.innerHTML = `
`; elements.actionsBar.style.display = 'flex'; // Restore event listeners setupUploadDropdown(); document.getElementById('new-folder-btn').addEventListener('click', async () => { const folderName = await window.Modal.promptNewFolder(); if (folderName) { fileOps.createFolder(folderName); } }); document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); // Restore cached elements 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'); // Hide shared view if it exists if (window.sharedView) { window.sharedView.hide(); } // Show standard files container const filesGrid = document.getElementById('files-grid'); if (filesGrid) { filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; } const filesListView = document.getElementById('files-list-view'); if (filesListView) { filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; } // Use user's home folder instead of root path if (app.userHomeFolderId) { app.currentPath = app.userHomeFolderId; ui.updateBreadcrumb(app.userHomeFolderName || 'Home'); } else { // If no home folder is set, this will trigger finding it in loadFiles() app.currentPath = ''; } loadFiles(); } /** * Switch to the favorites view */ function switchToFavoritesView() { // Hide other views app.isTrashView = false; app.isSharedView = false; // Set favorites view as active app.isFavoritesView = true; app.currentSection = 'favorites'; // Remove active class from all nav items elements.navItems.forEach(navItem => navItem.classList.remove('active')); // Find favorites nav item and make it active const favoritesNavItem = document.querySelector('.nav-item:nth-child(4)'); if (favoritesNavItem) { favoritesNavItem.classList.add('active'); } // Update UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.favorites') : 'Favorites'; // Clear breadcrumb and show root ui.updateBreadcrumb(''); // Hide shared view if it exists if (window.sharedView) { window.sharedView.hide(); } // Configure actions bar for favorites view elements.actionsBar.innerHTML = `
`; elements.actionsBar.style.display = 'flex'; // Restore view toggle event listeners document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); // Update cached elements elements.gridViewBtn = document.getElementById('grid-view-btn'); elements.listViewBtn = document.getElementById('list-view-btn'); // Show standard files containers const filesGrid = document.getElementById('files-grid'); const filesListView = document.getElementById('files-list-view'); if (filesGrid) { filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; } if (filesListView) { filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; } // Check if favorites module is initialized if (window.favorites) { // Display favorites window.favorites.displayFavorites(); } else { console.error('Favorites module not loaded or initialized'); // Show error in UI const filesGrid = document.getElementById('files-grid'); if (filesGrid) { filesGrid.innerHTML = `

Error loading the favorites module

`; } } } /** * Switch to the recent files view */ function switchToRecentFilesView() { // Hide other views app.isTrashView = false; app.isSharedView = false; app.isFavoritesView = false; // Set recent view as active app.isRecentView = true; app.currentSection = 'recent'; // Remove active class from all nav items elements.navItems.forEach(navItem => navItem.classList.remove('active')); // Find recent nav item and make it active const recentNavItem = document.querySelector('.nav-item:nth-child(3)'); if (recentNavItem) { recentNavItem.classList.add('active'); } // Update UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.recent') : 'Recent'; // Clear breadcrumb and show root ui.updateBreadcrumb(''); // Hide shared view if it exists if (window.sharedView) { window.sharedView.hide(); } // Configure actions bar for recent view elements.actionsBar.innerHTML = `
`; elements.actionsBar.style.display = 'flex'; // Add event listener for clear button document.getElementById('clear-recent-btn').addEventListener('click', () => { if (window.recent) { window.recent.clearRecentFiles(); window.recent.displayRecentFiles(); window.ui.showNotification('Cleanup completed', 'Recent files history has been cleared'); } }); // Restore view toggle event listeners document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); // Update cached elements elements.gridViewBtn = document.getElementById('grid-view-btn'); elements.listViewBtn = document.getElementById('list-view-btn'); // Show standard files containers const filesGrid = document.getElementById('files-grid'); const filesListView = document.getElementById('files-list-view'); if (filesGrid) { filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; } if (filesListView) { filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; } // Check if recent files module is initialized if (window.recent) { // Display recent files window.recent.displayRecentFiles(); } else { console.error('Recent files module not loaded or initialized'); // Show error in UI const filesGrid = document.getElementById('files-grid'); if (filesGrid) { filesGrid.innerHTML = `

Error loading the recent files module

`; } } } // Expose view switching functions globally window.switchToFilesView = switchToFilesView; window.switchToSharedView = switchToSharedView; window.switchToFavoritesView = switchToFavoritesView; window.switchToRecentFilesView = switchToRecentFilesView; /** * Fetch updated user data from the server (including storage usage) * This calls the /api/auth/me endpoint which also triggers storage recalculation */ async function refreshUserData() { const TOKEN_KEY = 'oxicloud_token'; const USER_DATA_KEY = 'oxicloud_user'; const token = localStorage.getItem(TOKEN_KEY); console.log('refreshUserData called, token:', token ? token.substring(0, 20) + '...' : 'null'); if (!token) { console.log('No valid token, skipping user data refresh'); return null; } try { console.log('Fetching /api/auth/me...'); const response = await fetch('/api/auth/me', { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); console.log('/api/auth/me response status:', response.status); if (!response.ok) { console.warn('Failed to fetch user data:', response.status); return null; } const userData = await response.json(); console.log('Refreshed user data from server:', userData); console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes); // Update local storage with fresh data localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData)); // Update storage display with actual values updateStorageUsageDisplay(userData); return userData; } catch (error) { console.error('Error refreshing user data:', error); return null; } } // Expose refreshUserData globally window.refreshUserData = refreshUserData; /** * Show User Profile modal with account details */ function showUserProfileModal() { const USER_DATA_KEY = 'oxicloud_user'; const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); const username = userData.username || 'User'; const email = userData.email || ''; const role = userData.role || 'user'; const initials = username.substring(0, 2).toUpperCase(); const usedBytes = userData.storage_used_bytes || 0; const quotaBytes = userData.storage_quota_bytes || 0; const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0; const barColor = percentage > 90 ? '#ef4444' : percentage > 70 ? '#f59e0b' : '#22c55e'; const t = (key, fallback) => (window.i18n && window.i18n.t) ? window.i18n.t(key) || fallback : fallback; // Remove existing modal if any const existing = document.getElementById('profile-modal-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'profile-modal-overlay'; overlay.className = 'about-modal-overlay'; overlay.innerHTML = `
${initials}

${username}

${email}

${role === 'admin' ? 'πŸ›‘οΈ Admin' : 'πŸ‘€ ' + t('user_menu.role_user', 'User')}
${t('storage.title', 'Storage')}
${percentage}% · ${formatFileSize(usedBytes)} / ${quotaBytes > 0 ? formatFileSize(quotaBytes) : '∞'}
`; document.body.appendChild(overlay); // Show with animation requestAnimationFrame(() => overlay.classList.add('show')); // Close handlers overlay.querySelector('#profile-modal-close').addEventListener('click', () => { overlay.classList.remove('show'); setTimeout(() => overlay.remove(), 200); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.classList.remove('show'); setTimeout(() => overlay.remove(), 200); } }); } /** * Check if user is authenticated and load user's home folder */ async function checkAuthentication() { try { const TOKEN_KEY = 'oxicloud_token'; const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token'; const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry'; const USER_DATA_KEY = 'oxicloud_user'; // --- OIDC exchange code handling --- // After OIDC login, the backend redirects here with ?oidc_code=... const urlParams = new URLSearchParams(window.location.search); const oidcCode = urlParams.get('oidc_code'); if (oidcCode) { console.log('OIDC exchange code detected, exchanging for tokens...'); try { const exchangeResponse = await fetch('/api/auth/oidc/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: oidcCode }) }); if (!exchangeResponse.ok) { const errText = await exchangeResponse.text(); console.error('OIDC token exchange failed:', exchangeResponse.status, errText); window.location.href = '/login?source=oidc_error'; return; } const data = await exchangeResponse.json(); console.log('OIDC token exchange successful'); // Store tokens (same logic as password login in auth.js) const token = data.access_token || data.token; const refreshToken = data.refresh_token || data.refreshToken; if (token) { localStorage.setItem(TOKEN_KEY, token); if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); // Parse JWT expiry let parsedExpiry = false; const tokenParts = token.split('.'); if (tokenParts.length === 3) { try { const payload = JSON.parse(atob(tokenParts[1])); if (payload.exp) { const expiryDate = new Date(payload.exp * 1000); if (!isNaN(expiryDate.getTime())) { localStorage.setItem(TOKEN_EXPIRY_KEY, expiryDate.toISOString()); parsedExpiry = true; } } } catch (e) { console.error('Error parsing JWT:', e); } } if (!parsedExpiry) { const expiry = new Date(); expiry.setDate(expiry.getDate() + 30); localStorage.setItem(TOKEN_EXPIRY_KEY, expiry.toISOString()); } // Store user data if (data.user) { localStorage.setItem(USER_DATA_KEY, JSON.stringify(data.user)); } // Clean URL and reload without the oidc_code param window.history.replaceState({}, document.title, '/'); window.location.reload(); return; } } catch (err) { console.error('OIDC exchange error:', err); window.location.href = '/login?source=oidc_error'; return; } } // Verify token exists const token = localStorage.getItem(TOKEN_KEY); if (!token) { console.log('No token found, redirecting to login'); window.location.href = '/login?source=app'; return; } // Token exists, proceed with app initialization console.log('Token found, proceeding with app initialization'); // Display user information if available const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); if (userData.username) { // Update user avatar with initials const userInitials = userData.username.substring(0, 2).toUpperCase(); document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => { el.textContent = userInitials; }); // Update user menu info const menuName = document.getElementById('user-menu-name'); const menuEmail = document.getElementById('user-menu-email'); if (menuName) menuName.textContent = userData.username; if (menuEmail) menuEmail.textContent = userData.email || ''; // Update storage usage information with cached data first (for fast display) updateStorageUsageDisplay(userData); // Then refresh user data from server in the background to get updated storage refreshUserData().then(freshData => { if (freshData) { console.log('Storage usage updated from server'); } }).catch(err => { console.warn('Could not refresh user data:', err); }); // Find and load the user's home folder resolveHomeFolder().then(() => loadFiles()); } else { // No user data but token exists β€” try to fetch from server console.log('No user data, attempting to fetch from server'); try { const freshData = await refreshUserData(); if (freshData && freshData.username) { const userInitials = freshData.username.substring(0, 2).toUpperCase(); document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => el.textContent = userInitials); updateStorageUsageDisplay(freshData); resolveHomeFolder().then(() => loadFiles()); } else { // Server didn't return valid user data β€” token is likely invalid console.warn('Could not retrieve user data, redirecting to login'); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(USER_DATA_KEY); window.location.href = '/login?source=invalid_session'; } } catch (err) { console.error('Failed to fetch user data:', err); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(USER_DATA_KEY); window.location.href = '/login?source=session_error'; } } } catch (error) { console.error('Error during authentication check:', error); // On error, redirect to login cleanly β€” never create fake tokens localStorage.removeItem('oxicloud_token'); localStorage.removeItem('oxicloud_refresh_token'); localStorage.removeItem('oxicloud_token_expiry'); localStorage.removeItem('oxicloud_user'); window.location.href = '/login?source=auth_error'; } } /** * Find the user's home folder and load it * @param {string} username - The current user's username */ /** * Resolve the user's home folder from the backend. * Since the backend now scopes GET /api/folders to the authenticated user, * we simply pick the first root-level folder returned. */ async function resolveHomeFolder() { if (app.userHomeFolderId) return; // Already resolved try { const token = localStorage.getItem('oxicloud_token'); const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; const response = await fetch('/api/folders', { headers }); if (!response.ok) { console.warn(`Could not fetch home folder: ${response.status}`); return; } const folders = await response.json(); const folderList = Array.isArray(folders) ? folders : []; if (folderList.length > 0) { const home = folderList[0]; app.userHomeFolderId = home.id; app.userHomeFolderName = home.name; app.currentPath = home.id; ui.updateBreadcrumb(home.name); console.log(`Home folder resolved: ${home.name} (${home.id})`); } else { console.warn('No root folders found for user'); app.currentPath = ''; ui.updateBreadcrumb(''); } } catch (error) { console.error('Error resolving home folder:', error); app.currentPath = ''; ui.updateBreadcrumb(''); } } /** * Logout - clear all auth data and redirect to login */ function logout() { // Variable names as per auth.js const TOKEN_KEY = 'oxicloud_token'; const REFRESH_TOKEN_KEY = 'oxicloud_refresh_token'; const TOKEN_EXPIRY_KEY = 'oxicloud_token_expiry'; const USER_DATA_KEY = 'oxicloud_user'; // Clear all authentication data localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(TOKEN_EXPIRY_KEY); localStorage.removeItem(USER_DATA_KEY); // Also clear session storage counters sessionStorage.removeItem('redirect_count'); // Redirect to login page with correct path window.location.href = '/login'; } /** * Update the storage usage display with the user's actual storage usage * @param {Object} userData - The user data object */ function updateStorageUsageDisplay(userData) { // Default values let usedBytes = 0; let quotaBytes = 10737418240; // Default 10GB let usagePercentage = 0; // Get values from user data if available if (userData) { usedBytes = userData.storage_used_bytes || 0; quotaBytes = userData.storage_quota_bytes || 10737418240; // 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 = formatFileSize(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 (window.i18n && window.i18n.t) { storageInfo.textContent = window.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})`); } // Initialize app when DOM is ready document.addEventListener('DOMContentLoaded', initApp);