From 9d598f7965ae89e2b0554de512971ffb76eb1e7f Mon Sep 17 00:00:00 2001 From: Miika Kuisma Date: Tue, 17 Feb 2026 18:50:08 +0000 Subject: [PATCH] Files tab for dashboard view (#2331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dashboard - Added files tab with basic folder and files listing. * clearing out some linter errors * Removed Public folder for now * Added "modified" column Subfolder are listed before the files * Dashboard - files tab: context menu basics * Dashboard - Files tab: subfolders can be opened. Items can be opened from more-menu or by double clicking on them * Dashboard - Files tab: use UIContentMenu instead of custom menu for "more menu" * Dashboard - Files tab: Delete implemented, Trash folder added * refactored context menu rendering * Implemented UIItem context menu functionalities Added path * Rename files and folders * Rename fix * tweaks and fixes * WIP: Multiple selection, drag and drop, trash folder improvements * clean * Files handling improvements * empty trash fix * grid view * grid view improvements * update icons context menu for multiple items shows file counts such as: Delete (9) * Path navifation buttons * better way to get user's directories * Added icons for various file types * Use puter.kv instead of window.user_preferences * sorting and column resizing * documentation of the functions * Style update. Dashboard colors now as CSS variables * refactored restoring from trash and emptying trash with proper callbacks & refreshing * select documents folder by default style tweaking * Drag to select files and folders * dark mode * style tweaks * removed some dead code.. * Keyboard shortcuts (copied from UIWindow - untested & in progress) * Add drag-and-drop file upload support to Dashboard Implement native file drag-and-drop from local filesystem to Dashboard: - Add dragster handlers for main content area, sidebar folders, and folder rows - Create uploadFiles() method with progress modal and directory refresh - Add visual feedback CSS for drop zones (highlight on hover) - Fix subfolder drop detection to prevent duplicate uploads - Add callback support to unzipItem/untarItem for directory refresh Co-Authored-By: Claude Opus 4.5 * style tweaks * Fix cut/paste functionality in Dashboard The cut operation was storing clipboard items as plain path strings, but window.move_clipboard_items() looks for .item DOM elements which don't exist in Dashboard (it uses .row elements). Added a Dashboard-specific moveClipboardItems() method that calls puter.fs.move() directly with UIDs, and updated cut to store objects with path and uid like copy does. Co-Authored-By: Claude Opus 4.5 * Add mobile phone layout optimizations for Dashboard - Add responsive CSS for screens ≤480px with two-row file list layout - Wrap item-size and item-modified in item-metadata div for mobile grid - Hide directories sidebar on mobile (use breadcrumbs for navigation) - Reorganize header with breadcrumbs on top row, nav buttons below - Add single-tap to open folders on touch devices - Fix context menu "Open" action in Dashboard by adding onOpen callback - Hide column headers on mobile since metadata is shown per-item Co-Authored-By: Claude Opus 4.5 * Add CSS variables to Dashboard bento elements for dark mode support Replace hardcoded color values with CSS custom properties throughout the bento card components to enable proper dark mode theming. Adds new variables for icon shadows, fancy headers, gradients, and usage bars with appropriate values for both light and dark color schemes. Co-Authored-By: Claude Opus 4.5 * Add spacebar image preview feature to Dashboard Files Pressing spacebar on a selected image file now shows a full-size preview popover centered over the files container. Features include: - Toggle behavior: press spacebar again to close - Auto-switch: selecting another file updates the preview - Click outside to dismiss - Works in both grid and list view Co-Authored-By: Claude Opus 4.5 * Add floating action bar for multi-file selection in Dashboard Files When 2+ files are selected, a floating action bar appears above the footer with quick access buttons for common operations: - Download, Cut, Copy, Delete for normal files - Restore and Delete Permanently for items in Trash The bar slides up with a smooth animation and disappears when clicking the background to deselect. Includes mobile-responsive styling with icon-only buttons on smaller screens. Co-Authored-By: Claude Opus 4.5 * Add mobile select mode for multi-file selection in Dashboard Files On mobile devices, users can now tap a select button in the path actions to enter select mode. In this mode, tapping files toggles their selection instead of opening them, with checkboxes appearing next to each item. The floating action bar appears with 1+ selected items (vs 2+ on desktop) and includes a Done button to exit select mode. Co-Authored-By: Claude Opus 4.5 * Center empty directory message in Dashboard Files Replace row-based layout with centered flex positioning for the "No files in this directory" message, improving visual presentation. Co-Authored-By: Claude Opus 4.5 * Fixed issue where clicking on files background created blue dot (drag rectangle selection bug) * Image preview size tweak * Add URL-based routing to Dashboard with hash fragments Enable shareable/bookmarkable URLs for Dashboard tabs and file paths. The URL hash reflects current location (e.g., #files/username/Documents, #usage, #account). Browser back/forward navigation now works for folder navigation, and direct URL entry loads the correct tab and path. Co-Authored-By: Claude Opus 4.5 * fixing draggable styles * drag clone fixes * Add smart column resizing and filename truncation to Dashboard Files - Add middle-truncation for filenames preserving extensions (e.g., "longfile…name.txt") - Double-click column resize handles to auto-fit content width - Reset saved column widths to defaults if they don't fit smaller screens - Apply truncation to both list and grid views Co-Authored-By: Claude Opus 4.5 * Separated dashboard styles into dashboard.css and loading it dynamically only when navigating to /dashboard * Files and folders can be dropped over path items in order to move them. This is useful when you want to move items into parent folders. * Use custom context menu component for item-more menu on mobile devices * Created callback system to undo_last_action so that dashboard file list would always refresh after undo operation * gray update * hovering folder while dragging items opens the folder * Fixed issue with hover-dropping * Fixed chained drag-over-folder-and-hover system * dark mode tweak * - [x] Right clicking on background of an empty folder doesn't trigger context menu - [x] Right clicking on path should keep path item active - [x] Sidebar folder don't have context menu - [x] Drag item should be opaque so I can see through it - [x] Transition animation issue when hovering path action buttons - [x] Double clicking on column resizer: add padding - [x] Load dashboard.css before loading the page content - [x] Mobile: opening menu, clicking outside menu should close it - [x] Mobile: on smaller screen there should be still two columns - [x] Mobile: file name truncation in grid view doesn't work * Added missing badges * WIP has_worker * display worker icon on the .js files which are workers * Display worker badge on worker files, hovering badge shows worker URL. Worker URL added to file properties modal. * disable "publish as website" from context menu for folders that are already published * Loading spinner for Files * tweaks and fixes * Files clean loading spinner * Files: worker URL in the properties modal is clickable link * use custom tooltip to display worker URL * refactored renderDirectory * Fix Sidebar folder highlighting * add Beta badge on Files tab * Implemeting socket based item updating * Put newly added items into the position where they would land when list is sorted * restore from trash refresh fix * Revert "Revert "Adds worker badge to .js files which have been registered as workers. Hovering badge shows popover with worker URL. File properties modal also shows clickable worker URL. (#2462)"" This reverts commit c2750f2ca1fa0aeb441dd9c42f7ae650690c4b8c. * worker in stat * update puterjs and frontend code to support returnWorkers in stat * Better way of handling worker info * Fixed issue where right clicking on background opened two instances of a context menu * Fixes issue where while renaming a file, double clicking on word opened the file * fixes issue where while dragging multiple items the top-most item was transparent resulting blurry mess * when opening a folder, update view instantly * Improved drag-to-select items * Added feature where typing selects matching file items Fixed issue where keyboard listener for file list was active when context menu was open * Fixed issues with File -> New in context menu Fixes issue where files-selection-actions menu didn't disappear after deleting items * Improved drag to select behavior Improved drag items Improved worker badge handling * Cancel zone for dragging items (when spring-loading subfolders) * context menu's submenu tweak * Fixed issue where resizing window didn't work (when using "Open in New Window" from context menu) * Disabled buttons on .files-selection-actions while drag-to-select is happening * Fixes issue where creating new items using right-clicking re-rendered the directory view --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: ProgrammerIn-wonderland <3838shah@gmail.com> --- .../UI/Dashboard/ContextMenu/ContextMenu.js | 251 ++ src/gui/src/UI/Dashboard/TabFiles.js | 3931 ++++++++++++++++- src/gui/src/UI/Dashboard/TabHome.js | 195 +- src/gui/src/UI/Dashboard/TabSecurity.js | 76 +- src/gui/src/UI/Dashboard/UIDashboard.js | 293 +- src/gui/src/UI/UIItem.js | 66 +- src/gui/src/UI/UIWindowItemProperties.js | 15 +- src/gui/src/UI/UIWindowPublishWebsite.js | 2 - src/gui/src/css/dashboard.css | 2891 ++++++++++++ src/gui/src/css/style.css | 1324 +----- src/gui/src/globals.js | 2 + src/gui/src/helpers.js | 51 +- .../src/helpers/generate_file_context_menu.js | 723 +++ .../helpers/get_html_element_from_options.js | 18 +- src/gui/src/helpers/new_context_menu_item.js | 2 +- src/gui/src/i18n/translations/en.js | 1 + src/gui/src/icons/worker.svg | 1 + src/gui/src/initgui.js | 23 + 18 files changed, 8427 insertions(+), 1438 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js create mode 100644 src/gui/src/css/dashboard.css create mode 100644 src/gui/src/helpers/generate_file_context_menu.js create mode 100644 src/gui/src/icons/worker.svg diff --git a/src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js b/src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js new file mode 100644 index 000000000..fc12e720e --- /dev/null +++ b/src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js @@ -0,0 +1,251 @@ +/** + * ContextMenuModal + * + * A mobile-friendly context menu modal that appears positioned over a target element. + * Adapted from voice-recorder project for Puter dashboard use. + */ + +/** + * Detects if device is touch-primary (mobile/tablet) + * @returns {boolean} + */ +function isTouchPrimaryDevice () { + return ( + window.matchMedia('(pointer: coarse)').matches && + window.matchMedia('(hover: none)').matches + ); +} + +export default class ContextMenuModal { + constructor (options = {}) { + this.onClose = options.onClose || (() => { + }); + this.backdrop = null; + this.modal = null; + this.menuItems = null; + this.ignoreInteractions = false; + + // Event handler references for cleanup + this.backdropClickHandler = null; + this.escapeKeyHandler = null; + this.itemClickHandler = null; + } + + /** + * Show the modal positioned over a specific element + * @param {Array} menuItems - Array of menu item objects or '-' for separator + * @param {DOMRect} targetRect - Bounding rectangle of the tapped item + */ + show (menuItems, targetRect) { + if ( this.backdrop ) return; // Already showing + + this.menuItems = menuItems; + + // Create backdrop + this.backdrop = document.createElement('div'); + this.backdrop.className = 'context-menu-modal-backdrop'; + + // Create modal dialog + this.modal = document.createElement('div'); + this.modal.className = 'context-menu-modal-dialog'; + + // Build modal content + this.modal.innerHTML = ` +
+ ${this.renderMenuItems(menuItems)} +
+ `; + + // Add modal to backdrop + this.backdrop.appendChild(this.modal); + + // Add to DOM + document.body.appendChild(this.backdrop); + + // Position modal after adding to DOM (so we can measure it) + this.positionModal(targetRect); + + // Setup event listeners + this.setupEventListeners(); + + // Ignore interactions briefly to prevent accidental selection on touch devices + this.ignoreInteractions = true; + setTimeout(() => { + this.ignoreInteractions = false; + }, 100); + + // Trigger animation + requestAnimationFrame(() => { + this.backdrop.classList.add('show'); + }); + } + + /** + * Position the modal over the target element + * @param {DOMRect} targetRect - Bounding rectangle of the target + */ + positionModal (targetRect) { + const isMobile = isTouchPrimaryDevice(); + const modalHeight = this.modal.offsetHeight; + const modalWidth = this.modal.offsetWidth; + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const margin = 20; // Minimum margin from viewport edges + + // Default: align with item left and top + let top = targetRect.top; + let left = targetRect.left; + + // Use target width as minimum, but allow modal to be wider if needed + const width = Math.max(targetRect.width, modalWidth); + + // Horizontal positioning - center over item if possible + const itemCenter = targetRect.left + (targetRect.width / 2); + const modalHalfWidth = width / 2; + + if ( itemCenter - modalHalfWidth >= margin && + itemCenter + modalHalfWidth <= viewportWidth - margin ) { + left = itemCenter - modalHalfWidth; + } else { + // Align with item left, but ensure within viewport + left = 20; //Math.max(margin, Math.min(left, viewportWidth - width - margin)); + } + + // Vertical positioning - ensure modal stays within viewport + if ( top + modalHeight > viewportHeight - margin ) { + // Would go off bottom, shift up + top = Math.max(margin, viewportHeight - modalHeight - margin); + } + + if ( top < margin ) { + top = margin; + } + + // Apply positioning + this.modal.style.top = `${top}px`; + this.modal.style.left = isMobile ? `${left}px` : '300px'; + this.modal.style.width = isMobile ? '90%' : 'auto'; + } + + /** + * Render menu items as HTML + * Supports both Puter format (html/onClick) and voice-recorder format (label/action) + * @param {Array} menuItems - Array of menu items + * @returns {string} HTML string + */ + renderMenuItems (menuItems) { + return menuItems.map((item, index) => { + // Handle separators + if ( item === '-' || item.is_divider ) { + return '
'; + } + + // Get label - support both formats + const label = item.label || item.html || ''; + + // Check for delete/danger styling + const isDelete = label.toLowerCase().includes('delete'); + const deleteClass = isDelete ? 'context-menu-item--delete' : ''; + + // Get icon - support both formats (HTML string or base64) + let iconHtml = ''; + if ( item.icon ) { + if ( item.icon.startsWith('data:') ) { + // Base64 image + iconHtml = ``; + } else { + // HTML string (SVG) + iconHtml = item.icon; + } + } + + return ` + + `; + }).join(''); + } + + /** + * Setup event listeners + */ + setupEventListeners () { + // Close on backdrop click + this.backdropClickHandler = (e) => { + if ( e.target === this.backdrop ) { + this.close(); + } + }; + this.backdrop.addEventListener('click', this.backdropClickHandler); + + // Handle menu item clicks + this.itemClickHandler = (e) => { + if ( this.ignoreInteractions ) return; + + const itemBtn = e.target.closest('.context-menu-item'); + if ( ! itemBtn ) return; + + const index = parseInt(itemBtn.dataset.index, 10); + const menuItem = this.menuItems[index]; + + if ( menuItem && menuItem !== '-' && !menuItem.is_divider ) { + // Support both action formats + const handler = menuItem.action || menuItem.onClick; + if ( handler ) { + this.close(); + // Execute action after close animation starts + setTimeout(() => { + handler(); + }, 50); + } + } + }; + this.modal.addEventListener('click', this.itemClickHandler); + + // Handle Escape key + this.escapeKeyHandler = (e) => { + if ( e.key === 'Escape' ) { + this.close(); + } + }; + document.addEventListener('keydown', this.escapeKeyHandler); + } + + /** + * Close the modal with animation + */ + close () { + if ( ! this.backdrop ) return; + + // Remove event listeners + if ( this.backdropClickHandler ) { + this.backdrop.removeEventListener('click', this.backdropClickHandler); + } + if ( this.itemClickHandler && this.modal ) { + this.modal.removeEventListener('click', this.itemClickHandler); + } + if ( this.escapeKeyHandler ) { + document.removeEventListener('keydown', this.escapeKeyHandler); + } + + // Trigger closing animation + this.backdrop.classList.remove('show'); + + // Remove from DOM after animation + setTimeout(() => { + if ( this.backdrop && this.backdrop.parentNode ) { + this.backdrop.parentNode.removeChild(this.backdrop); + } + this.backdrop = null; + this.modal = null; + this.menuItems = null; + this.onClose(); + }, 200); + } +} + +export { isTouchPrimaryDevice }; diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 0ac58a2b7..42a2781b8 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -17,22 +17,3937 @@ * along with this program. If not, see . */ +/* eslint-disable no-invalid-this */ +/* eslint-disable @stylistic/quotes */ +import path from '../../lib/path.js'; +import open_item from '../../helpers/open_item.js'; +import UIContextMenu from '../UIContextMenu.js'; +import UIWindowProgress from '../UIWindowProgress.js'; +import UIAlert from '../UIAlert.js'; +import generate_file_context_menu from '../../helpers/generate_file_context_menu.js'; +import truncate_filename from '../../helpers/truncate_filename.js'; +import update_title_based_on_uploads from '../../helpers/update_title_based_on_uploads.js'; +import new_context_menu_item from '../../helpers/new_context_menu_item.js'; +import ContextMenuModal from './ContextMenu/ContextMenu.js'; + +const icons = { + document: ``, + files: ``, + folder: ``, + more: ``, + newFolder: ``, + upload: ``, + trash: ``, + download: ``, + cut: ``, + copy: ``, + restore: ``, + list: ``, + grid: ``, + sort: ``, + select: ``, + done: ``, + worker: ``, +}; + +const { html_encode, SelectionArea } = window; + +/** + * TabFiles - File browser tab component for the Puter Dashboard. + * + * Provides a full-featured file management interface including: + * - Directory navigation with breadcrumb path + * - List and grid view modes + * - File sorting by name, size, or modification date + * - Drag-and-drop file operations (move, copy, shortcut) + * - Context menus for file/folder operations + * - File upload with progress tracking + * - Trash folder support with restore/permanent delete + * + * @module TabFiles + */ const TabFiles = { id: 'files', - label: 'My Files', - icon: ``, + label: 'Files', + icon: icons.files, + /** + * Generates the HTML template for the files tab. + * + * @returns {string} HTML string containing the file browser structure + */ html () { - let h = ''; - h += '

My Files

'; - h += '

Your files will appear here.

'; + let h = ` +
+
+ +
+
+
    +
  • Home
  • +
  • Desktop
  • +
  • Documents
  • +
  • Pictures
  • +
  • Public
  • +
  • Videos
  • +
  • Trash
  • +
+
+
+
+
+
+ + + +
+
+
+ + + + + +
+
+
+
+
${i18n('name')}
+
+
${i18n('size')}
+
+
${i18n('modified')}
+
+
+
+
+
+ +
+ + + + + + +
+
+
+ `; return h; }, - init ($el_window) { - // Files tab initialization logic can go here + /** + * Initializes the files tab with event listeners and state. + * + * Sets up folder click handlers, drag-and-drop zones, context menus, + * and restores persisted preferences (view mode, sort settings, column widths). + * + * @param {jQuery} $el_window - The jQuery-wrapped window/container element + * @returns {Promise} + */ + async init ($el_window) { + this.showSpinner(); + const _this = this; + window.dashboard_object = _this; + + // Dashboard-compatible item creator for use by helpers.js and socket handlers. + // Wraps renderItem() with a directory check so items are only added + // when the user is viewing the relevant directory. + window.UIDashboardFileItem = async function (file) { + if ( ! _this.currentPath ) return; + if ( _this.renderingDirectory ) return; + if ( _this._creatingItem ) return; + + const parentDir = path.dirname(file.path); + if ( _this.currentPath !== parentDir ) return; + + // Don't add if item already exists in the view + if ( $(`.files-tab .files .item[data-uid='${file.uid}']`).length > 0 ) return; + + await _this.renderItem(file); + + // Get the newly appended row (it's always last after renderItem) + const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${file.uid}']`); + if ( $newRow.length === 0 ) return; + + // Insert at correct sorted position + _this.insertAtSortedPosition($newRow, file); + + // Apply column widths to match existing rows + _this.applyColumnWidths(); + + // Highlight animation to indicate newly added item + $newRow.addClass('item-newly-added'); + }; + + this.renderingDirectory = false; + this._creatingItem = false; + this.activeMenuFileUid = null; + this.currentPath = null; + this.currentPath = null; + this.folderDwellTimer = null; + this.folderDwellTarget = null; + this.springLoadedActive = false; + this.springLoadedOriginalPath = null; + this.previewOpen = false; + this.previewCurrentUid = null; + this.typeSearchTerm = ''; + this.typeSearchTimeout = null; + this.selectModeActive = false; + this.currentView = await puter.kv.get('view_mode') || 'list'; + + // Sorting state + this.sortColumn = await puter.kv.get('sort_column') || 'name'; + this.sortDirection = await puter.kv.get('sort_direction') || 'asc'; + + // Column widths state (for resizing) + const savedWidths = await puter.kv.get('column_widths'); + this.columnWidths = savedWidths ? JSON.parse(savedWidths) : { + name: null, // auto/flex + size: 100, + modified: 120, + }; + + // Add touch-device class for touch devices to show .item-more button + if ( window.isMobile.phone || window.isMobile.tablet ) { + $el_window.find('.files-tab').addClass('touch-device'); + } + + // Create click handler for each folder item + $el_window.find('[data-folder]').each(function () { + const folderElement = this; + + folderElement.onclick = async () => { + const folderPath = folderElement.getAttribute('data-path'); + _this.pushNavHistory(folderPath); + _this.renderDirectory(folderPath); + }; + + // Context menu for sidebar folders + $(folderElement).on('contextmenu taphold', async (e) => { + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + $(folderElement).addClass('context-menu-active'); + const folderPath = folderElement.getAttribute('data-path'); + const items = _this.generateFolderContextMenu(folderPath); + const menu = UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + menu.onClose = () => { + $(folderElement).removeClass('context-menu-active'); + }; + }); + + // Make sidebar folders droppable + $(folderElement).droppable({ + accept: '.row', + tolerance: 'pointer', + + drop: async function (event, ui) { + // Clear dwell timer to prevent folder from opening after drop + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + + // Block if ctrl and trashed + const draggedPath = $(ui.draggable).attr('data-path'); + if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + ui.helper.data('dropped', true); + + // Get target folder path + const folderName = folderElement.getAttribute('data-folder'); + const directories = Object.keys(window.user.directories); + const targetPath = directories.find(f => f.endsWith(folderName)); + + if ( ! targetPath ) return; + + // Collect all items to move + const itemsToMove = [ui.draggable[0]]; + + // Add other selected items + $('.item-selected-clone').each(function () { + const sourceId = $(this).attr('data-id'); + const sourceItem = document.querySelector(`.row[data-id="${sourceId}"]`); + if ( sourceItem ) itemsToMove.push(sourceItem); + }); + + // Perform operation based on modifier keys + if ( event.ctrlKey ) { + // Copy + await window.copy_items(itemsToMove, targetPath); + } + else if ( event.altKey && window.feature_flags?.create_shortcut ) { + // Create shortcuts + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + + await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath); + } + } + else { + // Move + await window.move_items(itemsToMove, targetPath); + } + }, + + over: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(folderElement).addClass('active'); + + const folderPath = folderElement.getAttribute('data-path'); + + // Don't auto-open the current directory or trash + if ( folderPath === _this.currentPath || + folderPath === window.trash_path ) { + return; + } + + // Clear any existing dwell timer + clearTimeout(_this.folderDwellTimer); + + // Add visual feedback animation + $(folderElement).addClass('dwell-opening'); + _this.folderDwellTarget = folderElement; + + // Start dwell timer — navigate into folder after 700ms + _this.folderDwellTimer = setTimeout(async () => { + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + if ( ! _this.springLoadedActive ) { + _this.springLoadedOriginalPath = _this.currentPath; + } + _this.springLoadedActive = true; + $('.drag-cancel-zone').show(); + $(folderElement).removeClass('dwell-opening active'); + + _this.pushNavHistory(folderPath); + await _this.renderDirectory(folderPath); + + // Refresh jQuery UI droppable detection for the active drag + if ( $.ui.ddmanager && $.ui.ddmanager.current ) { + $.ui.ddmanager.current.helper.addClass('ui-draggable-dragging'); + $.ui.ddmanager.prepareOffsets($.ui.ddmanager.current); + } + }, 700); + } + }, + + out: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + // Clear dwell timer + if ( _this.folderDwellTarget === folderElement ) { + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + } + $(folderElement).removeClass('dwell-opening'); + + // Only remove active if it's not the currently selected folder + const folderName = folderElement.getAttribute('data-folder'); + const directories = Object.keys(window.user.directories); + const folderUid = window.user.directories[directories.find(f => f.endsWith(folderName))]; + + if ( folderUid !== _this.currentPath ) { + $(folderElement).removeClass('active'); + } + } + }, + }); + + // Add native file drop support to sidebar folders + $(folderElement).dragster({ + enter: function (_dragsterEvent, event) { + const e = event.originalEvent; + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const folderPath = folderElement.getAttribute('data-path'); + + // Don't allow drop on trash + if ( folderPath === window.trash_path ) { + return; + } + + $(folderElement).addClass('native-drop-target'); + }, + + leave: function (_dragsterEvent, _event) { + $(folderElement).removeClass('native-drop-target'); + }, + + drop: async function (_dragsterEvent, event) { + const e = event.originalEvent; + $(folderElement).removeClass('native-drop-target'); + + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const folderPath = folderElement.getAttribute('data-path'); + + // Block uploads to trash + if ( folderPath === window.trash_path ) { + return; + } + + if ( e.dataTransfer?.items?.length > 0 ) { + _this.uploadFiles(e.dataTransfer.items, folderPath); + } + + e.stopPropagation(); + e.preventDefault(); + return false; + }, + }); + }); + + // Clear selection when clicking empty area (but not after rubber band selection) + $el_window.find('.dashboard-tab-content').on('click', (e) => { + // Skip if this click is the end of a rubber band selection + if ( _this.rubberBandSelectionJustEnded ) { + _this.rubberBandSelectionJustEnded = false; + return; + } + if ( e.target === this || e.target.classList.contains('files') ) { + document.querySelectorAll('.files-tab .row.selected').forEach(r => { + r.classList.remove('selected'); + }); + _this.updateFooterStats(); + } + }); + + // Right-click on background shows folder context menu + $el_window.find('.files').on('contextmenu taphold', async (e) => { + // Dismiss taphold on non-touch devices + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) { + return; + } + // Only trigger if clicking directly on .files container (not on a row) + if ( e.target.classList.contains('files') || + e.target.classList.contains('files-list-view') || + e.target.classList.contains('files-grid-view') ) { + e.preventDefault(); + e.stopPropagation(); + // Clear selection when right-clicking background + document.querySelectorAll('.files-tab .row.selected').forEach(r => { + r.classList.remove('selected'); + }); + _this.updateFooterStats(); + const items = await _this.generateFolderContextMenu(); + UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + } + }); + + // Store reference to $el_window for later use (must be before createHeaderEventListeners) + this.$el_window = $el_window; + + this.createHeaderEventListeners($el_window); + this.createSelectionActionListeners($el_window); + this.initRubberBandSelection(); + this.initNativeFileDrop(); + + // Apply initial view mode from persisted preferences + + const $filesContainer = this.$el_window.find('.files-tab .files'); + const $tabContent = this.$el_window.find('.files-tab'); + if ( this.currentView === 'grid' ) { + $filesContainer.addClass('files-grid-view'); + $tabContent.addClass('files-grid-mode'); + this.$el_window.find('.view-toggle-btn').html(icons.list); + } else { + $filesContainer.addClass('files-list-view'); + this.$el_window.find('.view-toggle-btn').html(icons.grid); + } + + // Check for initial file path from URL routing + if ( window.dashboard_initial_file_path ) { + const initialPath = window.dashboard_initial_file_path; + delete window.dashboard_initial_file_path; // Clear so it only runs once + this.pushNavHistory(initialPath); + this.renderDirectory(initialPath, { skipUrlUpdate: true }); + } else { + // Auto-select Documents folder on initialization + const documentsFolder = $el_window.find('[data-folder="Documents"]'); + if ( documentsFolder.length ) { + documentsFolder.trigger('click'); + } + } + + // Setup keyboard shortcuts + this.setupKeyboardShortcuts(); + + // Refresh current directory when the user returns to this browser tab + document.addEventListener('visibilitychange', () => { + if ( document.visibilityState === 'visible' && this.currentPath ) { + this.renderDirectory(this.currentPath, { skipNavHistory: true, skipUrlUpdate: true }); + } + }); + }, + + /** + * Called when the Files tab becomes active. + * Updates the URL hash to reflect the current file path. + * + * @param {jQuery} _$el_window - The jQuery-wrapped window/container element (unused) + * @returns {void} + */ + onActivate (_$el_window) { + // Update URL to show current path when Files tab becomes active + if ( this.currentPath && window.is_dashboard_mode ) { + this.updateDashboardUrl(this.currentPath); + } + }, + + /** + * Checks if the Dashboard Files tab is currently active and visible. + * + * @returns {boolean} True if Dashboard is visible and Files tab is active + */ + isDashboardFilesActive () { + if ( !this.$el_window || !this.$el_window.is(':visible') ) return false; + const filesSection = this.$el_window.find('.dashboard-section-files'); + return filesSection.hasClass('active'); + }, + + /** + * Sets up Dashboard-specific keyboard shortcuts. + * + * Handles arrow navigation, selection, copy/cut/paste, delete, rename, etc. + */ + setupKeyboardShortcuts () { + const _this = this; + + $(document).on('keydown.tabfiles', async function (e) { + // Only handle if Dashboard Files tab is active + if ( ! _this.isDashboardFilesActive() ) return; + + const focused_el = document.activeElement; + + // Skip if user is typing in an input/textarea (except for Escape) + if ( $(focused_el).is('input, textarea') && e.which !== 27 ) return; + + // When a context menu is open, yield control to keyboard.js + if ( $('.context-menu').length > 0 ) { + if ( (e.which >= 37 && e.which <= 40) || e.which === 13 || e.which === 27 ) { + return; + } + if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) { + return; + } + } + + const $container = _this.$el_window.find('.files-tab .files'); + const $allRows = $container.find('.row'); + const $selectedRows = $container.find('.row.selected'); + + // F2 - Rename selected item + if ( e.which === 113 ) { + const $selectedRow = $selectedRows.first(); + if ( $selectedRow.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + const $nameEditor = $selectedRow.find('.item-name-editor'); + const $itemName = $selectedRow.find('.item-name'); + if ( $nameEditor.length > 0 ) { + $itemName.hide(); + $nameEditor.show().addClass('item-name-editor-active').focus().select(); + } + } + return false; + } + + // Enter - Open selected items + if ( e.which === 13 && !$(focused_el).hasClass('item-name-editor') ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + $selectedRows.each(function () { + const isDir = $(this).attr('data-is_dir') === '1'; + const itemPath = $(this).attr('data-path'); + if ( isDir ) { + _this.pushNavHistory(itemPath); + _this.renderDirectory(itemPath); + } else { + open_item({ item: this }); + } + }); + } + return false; + } + + // Escape - Cancel drag, clear selection, or cancel rename + if ( e.which === 27 ) { + // Cancel active drag operation + if ( window.an_item_is_being_dragged ) { + e.preventDefault(); + e.stopPropagation(); + + if ( _this.springLoadedActive ) { + _this.navigateBackFromSpringLoad(); + } + _this.springLoadedActive = false; + _this.springLoadedOriginalPath = null; + + // Force jQuery UI to end the drag + $(document).trigger('mouseup'); + + // Cleanup + $('.drag-cancel-zone').remove(); + $('.item-selected-clone').remove(); + $('.draggable-count-badge').remove(); + window.an_item_is_being_dragged = false; + $('.window-app-iframe').css('pointer-events', 'auto'); + return false; + } + + if ( $(focused_el).hasClass('item-name-editor') ) { + // Cancel rename - handled by item's own keyup handler + return; + } + $selectedRows.removeClass('selected'); + _this.updateFooterStats(); + return false; + } + + // Delete - Move to trash or permanently delete + if ( e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey)) ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + + // Check if any items are in trash (for permanent delete) + const trashedItems = $selectedRows.filter(function () { + return $(this).attr('data-path')?.startsWith(`${window.trash_path}/`); + }); + + if ( trashedItems.length > 0 ) { + // Permanent delete with confirmation + const alert_resp = await UIAlert({ + message: i18n('confirm_delete_multiple_items'), + buttons: [ + { label: i18n('delete'), type: 'primary' }, + { label: i18n('cancel') }, + ], + }); + if ( alert_resp === 'Delete' ) { + for ( const row of trashedItems.toArray() ) { + await window.delete_item(row); + } + } + } else { + // Move to trash + await window.move_items($selectedRows.toArray(), window.trash_path); + } + } + return false; + } + + // Ctrl/Cmd + A - Select all + if ( (e.ctrlKey || e.metaKey) && e.which === 65 ) { + e.preventDefault(); + e.stopPropagation(); + $allRows.addClass('selected'); + if ( $allRows.length > 0 ) { + window.active_element = $allRows.last().get(0); + window.latest_selected_item = $allRows.last().get(0); + } + _this.updateFooterStats(); + return false; + } + + // Ctrl/Cmd + C - Copy + if ( (e.ctrlKey || e.metaKey) && e.which === 67 ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + window.clipboard = []; + window.clipboard_op = 'copy'; + $selectedRows.each(function () { + if ( $(this).attr('data-path') !== window.trash_path ) { + window.clipboard.push({ + path: $(this).attr('data-path'), + uid: $(this).attr('data-uid'), + metadata: $(this).attr('data-metadata'), + }); + } + }); + } + return false; + } + + // Ctrl/Cmd + X - Cut + if ( (e.ctrlKey || e.metaKey) && e.which === 88 ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + window.clipboard = []; + window.clipboard_op = 'move'; + $selectedRows.each(function () { + window.clipboard.push({ + path: $(this).attr('data-path'), + uid: $(this).attr('data-uid'), + }); + }); + } + return false; + } + + // Ctrl/Cmd + V - Paste + if ( (e.ctrlKey || e.metaKey) && e.which === 86 ) { + if ( window.clipboard.length > 0 && _this.currentPath ) { + e.preventDefault(); + e.stopPropagation(); + // Don't allow paste in Trash unless it's a move operation + if ( _this.currentPath.startsWith(window.trash_path) && window.clipboard_op !== 'move' ) { + return false; + } + if ( window.clipboard_op === 'copy' ) { + window.copy_clipboard_items(_this.currentPath, null); + } else { + _this.moveClipboardItems(_this.currentPath).then(() => { + _this.renderDirectory(_this.currentPath); + }); + } + } + return false; + } + + // Arrow keys - Navigate items + if ( e.which >= 37 && e.which <= 40 ) { + e.preventDefault(); + e.stopPropagation(); + + if ( $allRows.length === 0 ) return false; + + // If nothing selected, select first item + if ( $selectedRows.length === 0 ) { + const $first = $allRows.first(); + $first.addClass('selected'); + window.active_element = $first.get(0); + window.latest_selected_item = $first.get(0); + $first.get(0).scrollIntoView({ block: 'nearest' }); + _this.updateFooterStats(); + return false; + } + + // Find current item and calculate next + const $current = $(window.latest_selected_item || $selectedRows.last().get(0)); + const currentIndex = $allRows.index($current); + let nextIndex = currentIndex; + + // Calculate grid dimensions for grid view + const isGridView = $container.hasClass('files-grid-view'); + let cols = 1; + if ( isGridView && $allRows.length > 1 ) { + const firstTop = $allRows.eq(0).offset().top; + for ( let i = 1; i < $allRows.length; i++ ) { + if ( $allRows.eq(i).offset().top !== firstTop ) { + cols = i; + break; + } + } + if ( cols === 1 ) cols = $allRows.length; // All on one row + } + + // Calculate next index based on arrow key + switch ( e.which ) { + case 37: // Left + nextIndex = Math.max(0, currentIndex - 1); + break; + case 38: // Up + nextIndex = Math.max(0, currentIndex - cols); + break; + case 39: // Right + nextIndex = Math.min($allRows.length - 1, currentIndex + 1); + break; + case 40: // Down + nextIndex = Math.min($allRows.length - 1, currentIndex + cols); + break; + } + + if ( nextIndex !== currentIndex ) { + const $next = $allRows.eq(nextIndex); + + if ( ! e.shiftKey ) { + // Normal navigation - clear selection + $allRows.removeClass('selected'); + } + + $next.addClass('selected'); + window.active_element = $next.get(0); + window.latest_selected_item = $next.get(0); + $next.get(0).scrollIntoView({ block: 'nearest' }); + _this.updateFooterStats(); + + // If preview is open, switch to newly selected file + if ( _this.previewOpen && !e.shiftKey ) { + const newUid = $next.attr('data-uid'); + if ( newUid !== _this.previewCurrentUid ) { + _this.showImagePreview($next); + } + } + } + + return false; + } + + // Space - Toggle image preview + if ( e.which === 32 ) { + e.preventDefault(); + e.stopPropagation(); + + // If preview is open, close it + if ( _this.previewOpen ) { + _this.closeImagePreview(); + return false; + } + + // Open preview for single selected image file + if ( $selectedRows.length === 1 ) { + const $row = $selectedRows.first(); + const isDir = $row.attr('data-is_dir') === '1'; + if ( ! isDir ) { + _this.showImagePreview($row); + } + } + return false; + } + + // Type-to-select: letter/number keys search items by name + if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) { + e.preventDefault(); + e.stopImmediatePropagation(); + + if ( _this.typeSearchTerm !== '' ) { + clearTimeout(_this.typeSearchTimeout); + } + + _this.typeSearchTimeout = setTimeout(() => { + _this.typeSearchTerm = ''; + }, 700); + + _this.typeSearchTerm += e.key.toLocaleLowerCase(); + + let matches = []; + const $currentSelected = $selectedRows.first(); + + // If selected item already matches, keep it + if ( $currentSelected.length === 1 ) { + const selectedName = ($currentSelected.attr('data-name') || '').toLowerCase(); + if ( selectedName.startsWith(_this.typeSearchTerm) ) { + return false; + } + } + + // Search all rows for matches + for ( let j = 0; j < $allRows.length; j++ ) { + const name = ($allRows.eq(j).attr('data-name') || '').toLowerCase(); + if ( name.startsWith(_this.typeSearchTerm) ) { + matches.push($allRows.get(j)); + } + } + + if ( matches.length > 0 ) { + // If multiple matches and one is selected, cycle past it + if ( $currentSelected.length > 0 && matches.length > 1 ) { + let match_index; + for ( let i = 0; i < matches.length - 1; i++ ) { + if ( $(matches[i]).is($currentSelected) ) { + match_index = i; + break; + } + } + if ( match_index !== undefined ) { + matches.splice(0, match_index + 1); + } + } + + // Deselect all, select the match + $allRows.removeClass('selected'); + $(matches[0]).addClass('selected'); + window.active_element = matches[0]; + window.latest_selected_item = matches[0]; + matches[0].scrollIntoView({ block: 'nearest' }); + _this.updateFooterStats(); + } + + return false; + } + }); + }, + + /** + * Shows an image preview popover for the selected file. + * + * Fetches a signed URL for the actual image and displays it in a centered + * popover. The popover can be dismissed by pressing spacebar or clicking outside. + * + * @param {jQuery} $row - The selected row element + * @returns {Promise} + */ + async showImagePreview ($row) { + const uid = $row.attr('data-uid'); + const fileName = $row.attr('data-name'); + const filePath = $row.attr('data-path'); + + // Check if it's an image file + const extension = fileName.split('.').pop().toLowerCase(); + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']; + if ( ! imageExtensions.includes(extension) ) { + return; + } + + // Get read URL for the actual image + const imageUrl = await puter.fs.getReadURL(filePath); + + // Remove any existing preview + $('.image-preview-popover').remove(); + + const $filesContainer = this.$el_window.find('.files-tab .files'); + const containerWidth = $filesContainer.width(); + const containerOffset = $filesContainer.offset(); + + const previewHtml = ` +
+ ${html_encode(fileName)} +
${html_encode(fileName)}
+
+ `; + + $('body').append(previewHtml); + const $popover = $('.image-preview-popover'); + + // Position centered over the files container + $popover.css({ + maxWidth: `${containerWidth - 40}px`, + width: '100%', + left: `${containerOffset.left + (containerWidth / 2)}px`, + top: `${containerOffset.top + ($filesContainer.height() / 2)}px`, + transform: 'translate(-50%, -50%)', + }); + + this.previewOpen = true; + this.previewCurrentUid = uid; + + // Close on click outside the popover + const _this = this; + $(document).on('click.imagepreview', (e) => { + if ( ! $(e.target).closest('.image-preview-popover').length ) { + _this.closeImagePreview(); + } + }); + }, + + /** + * Closes the image preview popover. + * + * @returns {void} + */ + closeImagePreview () { + $('.image-preview-popover').remove(); + $(document).off('click.imagepreview'); + this.previewOpen = false; + this.previewCurrentUid = null; + }, + + /** + * Sets up event listeners for header controls. + * + * Handles navigation buttons (back/forward/up), new folder, upload, + * view toggle, sort menu, and column header sorting. + * + * @returns {void} + */ + createHeaderEventListeners () { + const _this = this; + const fileInput = document.querySelector('#upload-file-dialog'); + + const el_window_navbar_back_btn = document.querySelector(`.path-btn-back`); + const el_window_navbar_forward_btn = document.querySelector(`.path-btn-forward`); + const el_window_navbar_up_btn = document.querySelector(`.path-btn-up`); + + // Back button + $(el_window_navbar_back_btn).on('click', function () { + // if history menu is open don't continue + if ( $(el_window_navbar_back_btn).hasClass('has-open-contextmenu') ) { + return; + } + if ( window.dashboard_nav_history_current_position > 0 ) { + window.dashboard_nav_history_current_position--; + const new_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(new_path); + } + }); + + // Back button (hold click) + $(el_window_navbar_back_btn).on('taphold', function () { + let items = []; + const pos = el_window_navbar_back_btn.getBoundingClientRect(); + + for ( let index = window.dashboard_nav_history_current_position - 1; index >= 0; index-- ) { + const history_item = window.dashboard_nav_history[index]; + + items.push({ + html: `${history_item === window.home_path ? i18n('home') : path.basename(history_item)}`, + val: index, + onClick: function (e) { + window.dashboard_nav_history_current_position = e.value; + const new_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(new_path); + }, + }); + } + + if ( items.length > 0 ) { + UIContextMenu({ + position: { top: pos.top + pos.height + 3, left: pos.left }, + parent_element: el_window_navbar_back_btn, + items: items, + }); + } + }); + + // Forward button + $(el_window_navbar_forward_btn).on('click', function () { + // if history menu is open don't continue + if ( $(el_window_navbar_forward_btn).hasClass('has-open-contextmenu') ) { + return; + } + if ( window.dashboard_nav_history_current_position < window.dashboard_nav_history.length - 1 ) { + window.dashboard_nav_history_current_position++; + const target_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(target_path); + } + }); + + // Forward button (hold click) + $(el_window_navbar_forward_btn).on('taphold', function () { + let items = []; + const pos = el_window_navbar_forward_btn.getBoundingClientRect(); + + for ( let index = window.dashboard_nav_history_current_position + 1; index < window.dashboard_nav_history.length; index++ ) { + const history_item = window.dashboard_nav_history[index]; + + items.push({ + html: `${history_item === window.home_path ? i18n('home') : path.basename(history_item)}`, + val: index, + onClick: function (e) { + window.dashboard_nav_history_current_position = e.value; + const new_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(new_path); + }, + }); + } + + if ( items.length > 0 ) { + UIContextMenu({ + parent_element: el_window_navbar_forward_btn, + position: { top: pos.top + pos.height + 3, left: pos.left }, + items: items, + }); + } + }); + + // Up button + $(el_window_navbar_up_btn).on('click', function () { + if ( _this.currentPath === '/' ) return; + + const target_path = path.resolve(path.join(_this.currentPath, '..')); + _this.pushNavHistory(target_path); + _this.renderDirectory(target_path); + }); + + // New folder button + document.querySelector('.new-folder-btn').onclick = async () => { + if ( ! _this.currentPath ) return; + try { + const result = await puter.fs.mkdir({ + path: `${_this.currentPath}/New Folder`, + rename: true, + overwrite: false, + }); + await _this.renderDirectory(_this.currentPath); + // Find and select the new folder, then activate rename + const newFolderRow = this.$el_window.find(`.files-tab .row[data-name="${result.name}"]`); + if ( newFolderRow.length > 0 ) { + newFolderRow.addClass('selected'); + window.activate_item_name_editor(newFolderRow[0]); + } + } catch ( err ) { + // Folder creation failed silently + } + }; + + // Upload input element + fileInput.onchange = async (e) => { + const files = e.target.files; + if ( !files || files.length === 0 ) return; + + let upload_progress_window; + let opid; + + puter.fs.upload(files, _this.currentPath, { + generateThumbnails: true, + init: async (operation_id, xhr) => { + opid = operation_id; + // create upload progress window + upload_progress_window = await UIWindowProgress({ + title: i18n('upload'), + icon: window.icons['app-icon-uploader.svg'], + operation_id: operation_id, + show_progress: true, + on_cancel: () => { + window.show_save_account_notice_if_needed(); + xhr.abort(); + }, + }); + // add to active_uploads + window.active_uploads[opid] = 0; + }, + // start + start: async function () { + // change upload progress window message to uploading + upload_progress_window.set_status('Uploading'); + upload_progress_window.set_progress(0); + }, + // progress + progress: async function (operation_id, op_progress) { + upload_progress_window.set_progress(op_progress); + // update active_uploads + window.active_uploads[opid] = op_progress; + // update title if window is not visible + if ( document.visibilityState !== 'visible' ) { + update_title_based_on_uploads(); + } + }, + // success + success: function (items) { + // Add action to actions_history for undo ability + const files = []; + if ( typeof items[Symbol.iterator] === 'function' ) { + for ( const item of items ) { + files.push(item.path); + } + } else { + files.push(items.path); + } + window.actions_history.push({ + operation: 'upload', + data: files, + }); + setTimeout(() => { + upload_progress_window.close(); + }, 1000); + window.show_save_account_notice_if_needed(); + // remove from active_uploads + delete window.active_uploads[opid]; + // refresh + _this.renderDirectory(_this.currentPath); + // Clear the input value to allow uploading the same file again + fileInput.value = ''; + document.querySelector('form').reset(); + }, + // error + error: async function (err) { + upload_progress_window.show_error(i18n('error_uploading_files'), err.message); + // remove from active_uploads + delete window.active_uploads[opid]; + }, + // abort + // eslint-disable-next-line no-unused-vars + abort: async function (operation_id) { + // remove from active_uploads + delete window.active_uploads[opid]; + }, + }); + }; + + // Upload button + document.querySelector('.upload-btn').onclick = async () => { + if ( ! this.currentPath ) return; + fileInput.click(); + }; + + // View toggle button + document.querySelector('.view-toggle-btn').onclick = () => { + this.toggleView(); + }; + + // Sort button (shows dropdown menu) + document.querySelector('.sort-btn').onclick = (e) => { + this.showSortMenu(e); + }; + + // Select mode toggle button (mobile only) + document.querySelector('.select-mode-btn').onclick = () => { + this.toggleSelectMode(); + }; + + // Column header sorting + this.$el_window.find('.header .columns .sortable').on('click', (e) => { + const column = $(e.currentTarget).attr('data-sort'); + if ( column ) { + this.handleSort(column); + } + }); + + // Initialize sort indicators + this.updateSortIndicators(); + + // Column resize handles + this.initColumnResizing(); + }, + + /** + * Creates event listeners for the floating selection action buttons. + * + * @param {jQuery} $el_window - The jQuery-wrapped window/container element + * @returns {void} + */ + createSelectionActionListeners ($el_window) { + const _this = this; + const $actions = $el_window.find('.files-selection-actions'); + + // Restore button (for trash items) + $actions.find('.restore-btn').on('click', async function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + for ( const row of selectedRows ) { + try { + await _this.restoreItem(row); + $(row).fadeOut(150, function () { + $(this).remove(); + }); + } catch ( err ) { + console.error('Failed to restore item:', err); + } + } + _this.updateFooterStats(); + }); + + // Download button + $actions.find('.download-btn').on('click', function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + if ( selectedRows.length >= 2 ) { + window.zipItems(Array.from(selectedRows), _this.currentPath, true); + } + }); + + // Cut button + $actions.find('.cut-btn').on('click', function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + window.clipboard_op = 'move'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ + path: $(row).attr('data-path'), + uid: $(row).attr('data-uid'), + }); + }); + }); + + // Copy button + $actions.find('.copy-btn').on('click', function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + window.clipboard_op = 'copy'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ path: $(row).attr('data-path') }); + }); + }); + + // Delete button + $actions.find('.delete-btn').on('click', async function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + + // Check if any items are in trash (for permanent delete) + const anyTrashed = Array.from(selectedRows).some(row => { + const rowPath = $(row).attr('data-path'); + return rowPath?.startsWith(`${window.trash_path}/`); + }); + + if ( anyTrashed ) { + const confirmed = await UIAlert({ + message: i18n('confirm_delete_multiple_items'), + buttons: [ + { label: i18n('delete'), type: 'primary' }, + { label: i18n('cancel') }, + ], + }); + if ( confirmed === 'Delete' ) { + for ( const row of selectedRows ) { + await window.delete_item(row); + } + } + } else { + window.move_items(Array.from(selectedRows), window.trash_path); + } + $actions.removeClass('visible'); + }); + + // Done button (exits select mode on mobile) + $actions.find('.done-btn').on('click', function () { + _this.exitSelectMode(); + }); + }, + + /** + * Updates the state of selection action buttons based on current selection. + * Hides download/copy for trashed items, changes delete label for trash. + * + * @param {Array} selectedRows - The selected row elements + * @returns {void} + */ + updateSelectionActionsState (selectedRows) { + const $actions = this.$el_window.find('.files-selection-actions'); + + const anyTrashed = Array.from(selectedRows).some(row => { + const rowPath = $(row).attr('data-path'); + return rowPath?.startsWith(`${window.trash_path}/`); + }); + + if ( anyTrashed ) { + // Show restore, hide download and copy for trashed items + $actions.find('.restore-btn').show(); + $actions.find('.download-btn').hide(); + $actions.find('.cut-btn').hide(); + $actions.find('.copy-btn').hide(); + // Change delete label to "Delete Permanently" + $actions.find('.delete-btn span').text(i18n('delete_permanently') || 'Delete Permanently'); + } else { + // Hide restore, show normal actions + $actions.find('.restore-btn').hide(); + $actions.find('.download-btn').show(); + $actions.find('.cut-btn').show(); + $actions.find('.copy-btn').show(); + $actions.find('.delete-btn span').text(i18n('delete')); + } + }, + + /** + * Initializes column resize functionality for list view. + * + * Enables drag-to-resize on column headers and persists widths to storage. + * + * @returns {void} + */ + initColumnResizing () { + const _this = this; + const $columns = this.$el_window.find('.header .columns'); + + this.applyColumnWidths(); + + $columns.find('.col-resize-handle').on('mousedown', function (e) { + e.preventDefault(); + e.stopPropagation(); + + const $handle = $(this); + const column = $handle.attr('data-resize'); + const $header = $columns; + const startX = e.pageX; + + // Get the column element to resize + let $targetColumn; + if ( column === 'name' ) { + $targetColumn = $header.find('.item-name'); + } else if ( column === 'size' ) { + $targetColumn = $header.find('.item-size'); + } else if ( column === 'modified' ) { + $targetColumn = $header.find('.item-modified'); + } + + const startWidth = $targetColumn.outerWidth(); + + $(document).on('mousemove.colresize', function (moveEvent) { + const diff = moveEvent.pageX - startX; + let newWidth = Math.max(60, startWidth + diff); // Minimum width of 60px + + // For name column, limit max width + if ( column === 'name' ) { + newWidth = Math.max(100, newWidth); + } + + _this.columnWidths[column] = newWidth; + _this.applyColumnWidths(); + }); + + $(document).on('mouseup.colresize', function () { + $(document).off('mousemove.colresize mouseup.colresize'); + puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)); + }); + }); + + // Double-click on resize handle to auto-fit column to longest content + $columns.find('.col-resize-handle').on('dblclick', function (e) { + e.preventDefault(); + e.stopPropagation(); + + const column = $(this).attr('data-resize'); + const $filesTab = _this.$el_window.find('.files-tab'); + const padding = 16; // 8px padding on each side + let maxWidth = 60; // Minimum width + + if ( column === 'name' ) { + maxWidth = 100; + $filesTab.find('.files.files-list-view .row:not(.header)').each(function () { + const fullName = $(this).attr('data-name'); + if ( fullName ) { + const textWidth = measureTextWidth(fullName) + padding; + maxWidth = Math.max(maxWidth + 10, textWidth); + } + }); + } else if ( column === 'size' ) { + $filesTab.find('.files.files-list-view .row:not(.header) .item-size').each(function () { + const text = $(this).text(); + if ( text ) { + const textWidth = measureTextWidth(text) + padding; + maxWidth = Math.max(maxWidth + 10, textWidth); + } + }); + } else if ( column === 'modified' ) { + $filesTab.find('.files.files-list-view .row:not(.header) .item-modified').each(function () { + const text = $(this).text(); + if ( text ) { + const textWidth = measureTextWidth(text) + padding; + maxWidth = Math.max(maxWidth + 10, textWidth); + } + }); + } + + // Apply the new width + _this.columnWidths[column] = Math.ceil(maxWidth); + _this.applyColumnWidths(); + puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)); + }); + }, + + /** + * Applies the current column widths to the header and file rows. + * Also truncates file names to fit the available width. + * Resets to defaults if saved widths don't fit the current screen. + * + * @returns {void} + */ + applyColumnWidths () { + const $filesTab = this.$el_window.find('.files-tab'); + const $container = $filesTab.find('.files'); + const containerWidth = $container.width(); + + // Fixed widths: icon(24) + spacers(4*3) + more(20) = 56px, plus some margin + const fixedWidth = 56 + 20; + + let nameWidth = this.columnWidths.name; + let sizeWidth = this.columnWidths.size || 100; + let modifiedWidth = this.columnWidths.modified || 120; + + // Check if total width exceeds container width + if ( containerWidth > 0 && nameWidth ) { + const totalWidth = fixedWidth + nameWidth + sizeWidth + modifiedWidth; + if ( totalWidth > containerWidth ) { + // Reset to defaults - columns don't fit + this.columnWidths = { + name: null, + size: 100, + modified: 120, + }; + nameWidth = null; + sizeWidth = 100; + modifiedWidth = 120; + } + } + + const nameCol = nameWidth ? `${nameWidth}px` : 'auto'; + const gridTemplate = `24px ${nameCol} 4px ${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`; + + $filesTab.find('.header .columns').css('grid-template-columns', gridTemplate); + $filesTab.find('.files.files-list-view .row').css('grid-template-columns', gridTemplate); + + // Apply middle-truncation to file names + if ( this.currentView === 'list' && nameWidth ) { + const padding = 16; // 8px padding on each side + const availableWidth = nameWidth - padding; + $filesTab.find('.files.files-list-view .row:not(.header) .item-name').each(function () { + const $name = $(this); + const fullName = $name.closest('.row').attr('data-name'); + if ( fullName ) { + $name.text(truncateFilenameToWidth(fullName, availableWidth)); + } + }); + } else if ( this.currentView === 'list' ) { + // Reset to full names when column is auto-width + $filesTab.find('.files.files-list-view .row:not(.header) .item-name').each(function () { + const $name = $(this); + const fullName = $name.closest('.row').attr('data-name'); + if ( fullName ) { + $name.text(fullName); + } + }); + } else if ( this.currentView === 'grid' ) { + // Apply middle-truncation in grid view + $filesTab.find('.files.files-grid-view .row .item-name').each(function () { + const $name = $(this); + const fullName = $name.closest('.row').attr('data-name'); + if ( fullName ) { + const itemWidth = $name.width() || 156; + $name.text(truncateFilenameToWidth(fullName, itemWidth)); + } + }); + } + }, + + /** + * Updates the sidebar folder selection to match the current path. + * + * @returns {void} + */ + updateSidebarSelection () { + this.$el_window.find('.directories li').removeClass('active'); + + const currentPath = this.currentPath; + if ( ! currentPath ) return; + + this.$el_window.find('[data-path]').each(function () { + const folderPath = this.getAttribute('data-path'); + if ( folderPath === currentPath ) { + this.classList.add('active'); + } + }); + }, + + /** + * Updates header action buttons based on current folder context. + * + * Shows/hides new folder, upload, and empty trash buttons as appropriate. + * + * @param {boolean} isTrashFolder - Whether the current folder is the Trash + * @returns {void} + */ + updateActionButtons (isTrashFolder) { + const $pathActions = this.$el_window.find('.path-actions'); + + if ( isTrashFolder ) { + $pathActions.find('.new-folder-btn, .upload-btn').hide(); + + if ( $pathActions.find('.empty-trash-btn').length === 0 ) { + const emptyTrashBtn = $(``); + $pathActions.append(emptyTrashBtn); + emptyTrashBtn.on('click', () => { + window.empty_trash(); + }); + } + $pathActions.find('.empty-trash-btn').show(); + } else { + $pathActions.find('.new-folder-btn, .upload-btn').show(); + $pathActions.find('.empty-trash-btn').hide(); + } + }, + + /** + * Displays the sort options context menu. + * + * @param {MouseEvent} e - The click event from the sort button + * @returns {void} + */ + showSortMenu (e) { + const _this = this; + + const sortOptions = [ + { column: 'name', label: 'Name' }, + { column: 'size', label: 'Size' }, + { column: 'modified', label: 'Date Modified' }, + ]; + + const items = sortOptions.map(opt => { + const isActive = _this.sortColumn === opt.column; + const directionIcon = _this.sortDirection === 'asc' ? ' ↑' : ' ↓'; + + return { + html: `${opt.label}${isActive ? directionIcon : ''}`, + checked: isActive, + onClick: () => { + _this.handleSort(opt.column); + }, + }; + }); + + UIContextMenu({ + items: items, + position: { left: e.pageX, top: e.pageY }, + }); + }, + + /** + * Sorts an array of files according to current sort settings. + * + * Folders are always sorted before files. Within each group, items are + * sorted by the selected column (name, size, or modified date). + * + * @param {Array} files - Array of file/folder objects to sort + * @returns {Array} Sorted array with folders first, then files + */ + sortFiles (files) { + const folders = files.filter(f => f.is_dir); + const regularFiles = files.filter(f => !f.is_dir); + + const getDisplayName = (file) => { + try { + const metadata = file.metadata ? JSON.parse(file.metadata) : {}; + return (metadata.original_name || file.name).toLowerCase(); + } catch { + return file.name.toLowerCase(); + } + }; + + const sortFn = (a, b) => { + let comparison = 0; + const aName = getDisplayName(a); + const bName = getDisplayName(b); + + switch ( this.sortColumn ) { + case 'name': + comparison = aName.localeCompare(bName); + break; + case 'size': + comparison = (a.size || 0) - (b.size || 0); + break; + case 'modified': + comparison = (a.modified || 0) - (b.modified || 0); + break; + default: + comparison = aName.localeCompare(bName); + } + + return this.sortDirection === 'asc' ? comparison : -comparison; + }; + + folders.sort(sortFn); + regularFiles.sort(sortFn); + + return [...folders, ...regularFiles]; + }, + + /** + * Moves a newly appended row to its correct sorted position among + * existing items. Folders always come before files; within each group, + * items are ordered by the current sortColumn and sortDirection. + * + * @param {jQuery} $newRow - The jQuery-wrapped row element to reposition + * @param {Object} file - The file object with name, size, modified, is_dir + */ + insertAtSortedPosition ($newRow, file) { + const $container = this.$el_window.find('.files-tab .files'); + const $existingRows = $container.find('.item.row').not($newRow); + + if ( $existingRows.length === 0 ) return; + + const newIsDir = !!file.is_dir; + const newName = (file.name || '').toLowerCase(); + const newSize = file.size || 0; + const newModified = file.modified || 0; + const sortColumn = this.sortColumn; + const sortDirection = this.sortDirection; + + $existingRows.each(function () { + const $existing = $(this); + const existingIsDir = $existing.attr('data-is_dir') === '1'; + + // Folders always come before files + if ( newIsDir && !existingIsDir ) { + $newRow.insertBefore($existing); + return false; + } + if ( !newIsDir && existingIsDir ) { + return true; + } + + // Same type — compare by sort column + let comparison = 0; + switch ( sortColumn ) { + case 'name': + comparison = newName.localeCompare(($existing.attr('data-name') || '').toLowerCase()); + break; + case 'size': + comparison = newSize - (parseInt($existing.attr('data-size')) || 0); + break; + case 'modified': + comparison = newModified - (parseInt($existing.attr('data-modified')) || 0); + break; + default: + comparison = newName.localeCompare(($existing.attr('data-name') || '').toLowerCase()); + } + + if ( sortDirection !== 'asc' ) comparison = -comparison; + + if ( comparison < 0 ) { + $newRow.insertBefore($existing); + return false; + } + }); + + // If not inserted, it belongs at the end (already there from append) + }, + + /** + * Handles sort column selection or direction toggle. + * + * Clicking the same column toggles direction; clicking a new column + * sets ascending order. Persists settings and re-renders the directory. + * + * @param {string} column - Column name to sort by ('name', 'size', or 'modified') + * @returns {Promise} + */ + async handleSort (column) { + if ( this.sortColumn === column ) { + this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.sortColumn = column; + this.sortDirection = 'asc'; + } + + await puter.kv.set('sort_column', this.sortColumn); + await puter.kv.set('sort_direction', this.sortDirection); + + this.updateSortIndicators(); + this.renderDirectory(this.currentPath); + }, + + /** + * Updates visual sort indicators on column headers. + * + * @returns {void} + */ + updateSortIndicators () { + if ( ! this.$el_window ) return; + + const $columns = this.$el_window.find('.header .columns'); + + $columns.find('.sortable').removeClass('sort-asc sort-desc'); + + const $activeColumn = $columns.find(`.sortable[data-sort="${this.sortColumn}"]`); + $activeColumn.addClass(this.sortDirection === 'asc' ? 'sort-asc' : 'sort-desc'); + }, + + /** + * Renders the contents of a directory. + * + * Fetches directory contents, applies sorting, renders each item, + * and updates navigation UI elements. + * + * @param {string} uid - The UID or path of the directory to render + * @param {Object} [options] - Optional settings + * @param {boolean} [options.skipUrlUpdate] - If true, don't update browser URL + * @param {boolean} [options.skipNavHistory] - If true, don't add to navigation history + * @returns {Promise} + */ + async renderDirectory (target, options = {}) { + if ( this.renderingDirectory ) return; + this.renderingDirectory = true; + this.$el_window.find('.files-tab .files').html(''); + this.showSpinner(); + const _this = this; + + document.querySelectorAll('.files-tab .row.selected').forEach(r => { + r.classList.remove('selected'); + }); + + // Determine whether target is a path or uid + const isPath = typeof target === 'string' && target.startsWith('/'); + const readdirArg = isPath + ? { path: target, consistency: options.consistency || 'eventual' } + : { uid: target, consistency: options.consistency || 'eventual' }; + const directoryContents = await window.puter.fs.readdir(readdirArg); + if ( ! directoryContents ) { + this.hideSpinner(); + this.renderingDirectory = false; + return; + } + + // Resolve path: if target was a path we already know it, + // otherwise look it up from known user directories. + if ( isPath ) { + this.currentPath = target; + } else { + let path = null; + Object.entries(window.user.directories).forEach(o => { + if ( o[1] === target ) { + path = o[0]; + } + }); + this.currentPath = path || target; + } + + // Update browser URL to reflect current file path (only when Files tab is active) + if ( !options.skipUrlUpdate && window.is_dashboard_mode && this.isDashboardFilesActive() ) { + this.updateDashboardUrl(this.currentPath); + } + + this.updateSidebarSelection(); + + const isTrashFolder = this.currentPath === window.trash_path; + this.updateActionButtons(isTrashFolder); + + $('.path-breadcrumbs').html(this.renderPath(this.currentPath, window.user.username)); + $('.path-breadcrumbs .dirname').each(function () { + const dirnameElement = this; + const clickedPath = dirnameElement.getAttribute("data-path"); + + dirnameElement.onclick = () => { + _this.pushNavHistory(clickedPath); + _this.renderDirectory(clickedPath); + }; + + $(dirnameElement).on('contextmenu taphold', async (e) => { + // Dismiss taphold on non-touch devices + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + $(dirnameElement).addClass('context-menu-active'); + const items = _this.generateFolderContextMenu(clickedPath); + const menu = UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + menu.onClose = () => { + $(dirnameElement).removeClass('context-menu-active'); + }; + }); + + // Make breadcrumb items droppable for file/folder moves + $(dirnameElement).droppable({ + accept: '.row', + tolerance: 'pointer', + + drop: async function (event, ui) { + const targetPath = $(this).attr('data-path'); + const draggedPath = $(ui.draggable).attr('data-path'); + + // Block copying trashed items + if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + // Don't drop on current directory + if ( targetPath === _this.currentPath ) { + return; + } + + ui.helper.data('dropped', true); + + // Collect all items to move (primary + any selected clones) + const itemsToMove = [ui.draggable[0]]; + $('.item-selected-clone').each(function () { + const sourceId = $(this).attr('data-id'); + const sourceItem = document.querySelector(`.row[data-id="${sourceId}"]`); + if ( sourceItem ) itemsToMove.push(sourceItem); + }); + + // Perform operation based on modifier keys + if ( event.ctrlKey ) { + await window.copy_items(itemsToMove, targetPath); + } else if ( event.altKey && window.feature_flags?.create_shortcut ) { + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath); + } + } else { + await window.move_items(itemsToMove, targetPath); + } + }, + + over: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(this).addClass('drop-target'); + } + }, + + out: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(this).removeClass('drop-target'); + } + }, + }); + }); + + if ( directoryContents.length === 0 ) { + this.$el_window.find('.files-tab .files').append(`
+ No files in this directory. + `); + this.updateFooterStats(); + this.updateNavButtonStates(); + this.hideSpinner(); + this.renderingDirectory = false; + return; + } + + const sortedContents = this.sortFiles(directoryContents); + await Promise.all(sortedContents.map(file => this.renderItem(file))); + + this.applyColumnWidths(); + this.updateFooterStats(); + this.updateNavButtonStates(); + this.hideSpinner(); + this.renderingDirectory = false; + }, + + /** + * Renders a single file or folder item as a row in the file list. + * + * Creates the DOM element with appropriate data attributes and appends + * it to the files container, then attaches event listeners. + * + * @param {Object} file - The file/folder object from the filesystem API + * @returns {void} + */ + async renderItem (file) { + // For trashed items, use original_name from metadata if available + const item_id = window.global_element_id++; + const metadata = JSON.parse(file.metadata) || {}; + const displayName = metadata.original_name || file.name; + let website_url = window.determine_website_url(file.path); + const is_shared_with_me = (file.path !== `/${window.user.username}` && !file.path.startsWith(`/${window.user.username}/`)); + const is_worker = file.workers?.length > 0; + const worker_url = is_worker ? file.workers[0]?.address : ''; + const icon = file.is_dir ? `` : ((file.thumbnail && this.currentView === 'grid') ? `${displayName}` : this.determineIcon(file)); + const row = document.createElement("div"); + row.setAttribute('class', `item row ${file.is_dir ? 'folder' : 'file'}`); + row.setAttribute("data-id", item_id); + row.setAttribute("data-name", displayName); + row.setAttribute("data-uid", file.uid); + row.setAttribute("data-is_dir", file.is_dir ? "1" : "0"); + row.setAttribute("data-is_trash", file.is_trash ? "1" : "0"); + row.setAttribute("data-has_website", file.has_website ? "1" : "0"); + row.setAttribute("data-website_url", website_url ? html_encode(website_url) : ''); + row.setAttribute("data-immutable", file.immutable ? "1" : "0"); + row.setAttribute("data-is_shortcut", file.is_shortcut); + row.setAttribute("data-shortcut_to", html_encode(file.shortcut_to)); + row.setAttribute("data-shortcut_to_path", html_encode(file.shortcut_to_path)); + row.setAttribute("data-is_worker", is_worker !== undefined ? "1" : "0"); + row.setAttribute("data-worker_url", is_worker !== undefined ? worker_url : "0"); + row.setAttribute("data-sortable", file.sortable ?? 'true'); + row.setAttribute("data-metadata", JSON.stringify(metadata)); + row.setAttribute("data-sort_by", html_encode(file.sort_by) ?? 'name'); + row.setAttribute("data-size", file.size); + row.setAttribute("data-type", html_encode(file.type) ?? ''); + row.setAttribute("data-modified", file.modified); + row.setAttribute("data-associated_app_name", html_encode(file.associated_app_name) ?? ''); + row.setAttribute("data-path", html_encode(file.path)); + row.innerHTML = ` +
+
+ ${icon} +
+
+ + + + + + +
+
+
${displayName}
+ +
+
+ +
+
${icons.more}
+ `; + this.$el_window.find('.files-tab .files').append(row); + + this.createItemListeners(row, file); + }, + + /** + * Determines the appropriate icon for a file based on its extension. + * + * @param {Object} file - The file object containing the filename + * @returns {string} HTML string for the icon image element + */ + determineIcon (file) { + const extension = file.name.split('.').pop().toLowerCase(); + switch ( extension ) { + case 'm4a': + case 'ogg': + case 'aac': + case 'flac': + return ``; + case 'cpp': + return ``; + case 'css': + return ``; + case 'csv': + return ``; + case 'doc': + case 'docx': + return ``; + case 'exe': + return ``; + case 'gzip': + return ``; + case 'html': + return ``; + case 'jpg': + case 'jpeg': + case 'png': + case 'webp': + case 'gif': + return ``; + case 'jar': + return ``; + case 'java': + return ``; + case 'js': + return ``; + case 'json': + return ``; + case 'jsp': + return ``; + case 'log': + return ``; + case 'md': + return ``; + case 'mp3': + return ``; + case 'otf': + return ``; + case 'pdf': + return ``; + case 'php': + return ``; + case 'pptx': + return ``; + case 'psd': + return ``; + case 'py': + return ``; + case 'rss': + return ``; + case 'rtf': + return ``; + case 'ruby': + return ``; + case 'sketch': + return ``; + case 'sql': + return ``; + case 'svg': + return ``; + case 'tar': + return ``; + case 'tpl': + case 'xltx': + case 'potx': + case 'tmpl': + return ``; + case 'text': + case 'txt': + return ``; + case 'tif': + return ``; + case 'tiff': + return ``; + case 'ttf': + return ``; + case 'mp4': + case 'avi': + case 'mov': + case 'wmf': + case 'mkv': + case 'webm': + return ``; + case 'wav': + return ``; + case 'xlsx': + return ``; + case 'xml': + return ``; + case 'zip': + return ``; + default: + return ``; + } + }, + + /** + * Attaches event listeners to a file/folder row element. + * + * Handles selection, double-click to open, rename functionality, + * context menus, and drag-and-drop operations. + * + * @param {HTMLElement} el_item - The row DOM element + * @param {Object} file - The file/folder object data + * @returns {void} + */ + createItemListeners (el_item, file) { + const _this = this; + const el_item_name = el_item.querySelector(`.item-name`); + const el_item_icon = el_item.querySelector('.item-icon'); + const el_item_name_editor = el_item.querySelector(`.item-name-editor`); + const isFolder = el_item.getAttribute('data-is_dir'); + let website_url = window.determine_website_url(file.path); + let rename_cancelled = false; + let shift_clicked = false; + let itemWasSelectedOnMousedown = false; + + el_item.onpointerdown = (e) => { + if ( e.target.classList.contains('item-more') ) return; + if ( el_item.classList.contains('header') ) return; + + shift_clicked = false; + + // Track whether item was already selected before this mousedown + itemWasSelectedOnMousedown = el_item.classList.contains('selected'); + + if ( e.which === 3 && el_item.classList.contains('selected') && + el_item.parentElement.querySelectorAll('.row.selected').length > 1 ) { + return; + } + + // Handle Shift+Click for range selection + if ( e.shiftKey && window.latest_selected_item && window.latest_selected_item !== el_item ) { + e.preventDefault(); + shift_clicked = true; + + const allRows = $(el_item).parent().find('.row').toArray(); + const clickedIndex = allRows.indexOf(el_item); + const lastSelectedIndex = allRows.indexOf(window.latest_selected_item); + + if ( clickedIndex !== -1 && lastSelectedIndex !== -1 ) { + const start = Math.min(clickedIndex, lastSelectedIndex); + const end = Math.max(clickedIndex, lastSelectedIndex); + + // Clear selection if no Ctrl/Cmd held + if ( !e.ctrlKey && !e.metaKey ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + } + + // Select all items in range + for ( let i = start; i <= end; i++ ) { + allRows[i].classList.add('selected'); + } + + // Update latest selected to the clicked item + window.latest_selected_item = el_item; + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + _this.updateFooterStats(); + return; + } + } + + // In select mode on mobile, treat taps like Ctrl+click (toggle selection) + const isMobileSelectMode = (window.isMobile.phone || window.isMobile.tablet) && _this.selectModeActive; + + // If clicking on .item-name, .item-icon, or .item-badges, select immediately so item drag works + const isDragHandle = e.target.closest('.item-name, .item-icon, .item-badges'); + if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode && isDragHandle ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + el_item.classList.add('selected'); + window.latest_selected_item = el_item; + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + itemWasSelectedOnMousedown = true; + _this.updateFooterStats(); + return; + } + + // If item is NOT selected and no modifier keys: defer selection to click handler. + // This allows rubberband selection to start when dragging from unselected items. + if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode ) { + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + return; + } + + if ( !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + } + + if ( ! e.shiftKey ) { + if ( ((e.ctrlKey || e.metaKey) || isMobileSelectMode) && el_item.classList.contains('selected') ) { + el_item.classList.remove('selected'); + } else { + el_item.classList.add('selected'); + window.latest_selected_item = el_item; + } + } + + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + _this.updateFooterStats(); + + // If preview is open, switch to newly selected file + if ( _this.previewOpen ) { + const $container = $(el_item).closest('.files'); + const $newSelected = $container.find('.row.selected'); + if ( $newSelected.length === 1 ) { + const newUid = $newSelected.attr('data-uid'); + if ( newUid !== _this.previewCurrentUid ) { + _this.showImagePreview($newSelected); + } + } + } + }; + + el_item.onclick = (e) => { + if ( e.target.classList.contains('item-more') ) { + this.handleMoreClick(el_item, file, e.target); + return; + } + + // Skip if this click is the end of a rubber band selection + if ( _this.rubberBandSelectionJustEnded ) { + _this.rubberBandSelectionJustEnded = false; + return; + } + + // Skip if this was a shift-click (already handled in pointerdown) + if ( shift_clicked ) { + shift_clicked = false; + return; + } + + // On mobile in select mode, selection was already handled in pointerdown + // Just return early to prevent any further processing + if ( (window.isMobile.phone || window.isMobile.tablet) && _this.selectModeActive ) { + return; + } + + if ( !e.ctrlKey && !e.metaKey && !e.shiftKey ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + if ( r !== el_item ) r.classList.remove('selected'); + }); + // Ensure clicked item is selected (handles deferred selection from pointerdown) + if ( ! el_item.classList.contains('selected') ) { + el_item.classList.add('selected'); + window.latest_selected_item = el_item; + } + } + _this.updateFooterStats(); + + // If preview is open, switch to newly selected file + if ( _this.previewOpen ) { + const $container = $(el_item).closest('.files'); + const $newSelected = $container.find('.row.selected'); + if ( $newSelected.length === 1 ) { + const newUid = $newSelected.attr('data-uid'); + if ( newUid !== _this.previewCurrentUid ) { + _this.showImagePreview($newSelected); + } + } + } + + // On mobile, single tap opens folders (no double-tap on touch devices) + if ( window.isMobile.phone || window.isMobile.tablet ) { + // Normal mode: open the item + if ( isFolder === "1" ) { + _this.pushNavHistory(file.path); + _this.renderDirectory(file.path); + } else { + open_item({ item: el_item }); + } + el_item.classList.remove('selected'); + } + }; + + el_item.ondblclick = (e) => { + if ( e.target.classList.contains('item-name-editor') ) { + return; + } + if ( isFolder === "1" ) { + _this.pushNavHistory(file.path); + _this.renderDirectory(file.path); + } else { + open_item({ item: el_item }); + } + el_item.classList.remove('selected'); + }; + + // -------------------------------------------------------- + // Rename + // -------------------------------------------------------- + function rename () { + if ( rename_cancelled ) { + rename_cancelled = false; + return; + } + + const old_name = $(el_item).attr('data-name'); + const old_path = $(el_item).attr('data-path'); + const new_name = $(el_item_name_editor).val(); + + // Don't send a rename request if: + // the new name is the same as the old one, + // or it's empty, + // or editable was not even active at all + if ( old_name === new_name || !new_name || new_name === '.' || new_name === '..' || !$(el_item_name_editor).hasClass('item-name-editor-active') ) { + if ( new_name === '.' ) { + UIAlert('The name "." is not allowed, because it is a reserved name. Please choose another name.'); + } + else if ( new_name === '..' ) { + UIAlert('The name ".." is not allowed, because it is a reserved name. Please choose another name.'); + } + $(el_item_name).html(html_encode(truncate_filename(file.name))); + $(el_item_name).show(); + $(el_item_name_editor).val($(el_item).attr('data-name')); + $(el_item_name_editor).hide(); + return; + } + // deactivate item name editable + $(el_item_name_editor).removeClass('item-name-editor-active'); + + // Perform rename request + window.rename_file(file, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, false, (new_name) => { + $(el_item_name).html(html_encode(new_name)); + }); + } + + // -------------------------------------------------------- + // Rename if enter pressed on Item Name Editor + // -------------------------------------------------------- + $(el_item_name_editor).on('keypress', function (e) { + // If name editor is not active don't continue + if ( ! $(el_item_name_editor).is(':visible') ) + { + return; + } + + // Enter key = rename + if ( e.which === 13 ) { + e.stopPropagation(); + e.preventDefault(); + $(el_item_name_editor).blur(); + $(el_item).addClass('selected'); + window.last_enter_pressed_to_rename_ts = Date.now(); + window.update_explorer_footer_selected_items_count($(el_item).closest('.item-container')); + return false; + } + }); + + // -------------------------------------------------------- + // Cancel and undo if escape pressed on Item Name Editor + // -------------------------------------------------------- + $(el_item_name_editor).on('keyup', function (e) { + if ( ! $(el_item_name_editor).is(':visible') ) + { + return; + } + + // Escape = undo rename + else if ( e.which === 27 ) { + e.stopPropagation(); + e.preventDefault(); + rename_cancelled = true; + $(el_item_name_editor).hide(); + $(el_item_name_editor).val(file.name); + $(el_item_name).show(); + } + }); + + $(el_item_name_editor).on('focusout', function (e) { + e.stopPropagation(); + e.preventDefault(); + rename(); + }); + + // Right-click context menu handler (desktop) and taphold (touch devices) + $(el_item).on('contextmenu taphold', async (e) => { + // Dismiss taphold on non-touch devices + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + if ( selectedRows.length > 1 && el_item.classList.contains('selected') ) { + const items = await _this.generateMultiSelectContextMenu(selectedRows); + UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + } else { + const items = await _this.generateContextMenuItems(el_item, file); + UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + } + }); + + // Skip header row for drag-and-drop + if ( el_item.classList.contains('header') ) return; + + $(el_item).draggable({ + appendTo: 'body', + refreshPositions: true, + helper: function () { + const $clone = $(el_item).clone(); + + // Wrap in container structure so CSS selectors match + const viewClass = _this.currentView === 'grid' ? 'files-grid-view' : 'files-list-view'; + const $wrapper = $(`
`); + $wrapper.find('.files').append($clone); + + // In grid view, set fixed width since the grid auto-fill + // doesn't work without a proper parent width context + if ( _this.currentView === 'grid' ) { + $clone.css('width', $(el_item).outerWidth()); + $wrapper.find('.files').css('display', 'block'); + } + + return $wrapper; + }, + revert: 'invalid', + zIndex: 10000, + scroll: false, + distance: 5, + revertDuration: 100, + + start: function (_event, ui) { + // Don't start drag if item wasn't already selected before mousedown; + // rubberband selection should handle this case instead. + if ( ! itemWasSelectedOnMousedown ) { + return false; + } + + if ( $(el_item).attr('data-immutable') !== '0' ) { + return false; + } + + if ( ! el_item.classList.contains('selected') ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + el_item.classList.add('selected'); + } + + ui.helper.addClass('selected'); + + // Clone other selected items with proper container structure + const viewClass = _this.currentView === 'grid' ? 'files-grid-view' : 'files-list-view'; + $(el_item).siblings('.row.selected').each(function () { + const $clone = $(this).clone(); + const $wrapper = $(`
`); + $wrapper.find('.files').append($clone); + $wrapper.css('position', 'absolute').appendTo('body').hide(); + }); + + const itemCount = $('.item-selected-clone').length; + if ( itemCount > 0 ) { + $('body').append(`${itemCount + 1}`); + } + + window.an_item_is_being_dragged = true; + $('.window-app-iframe').css('pointer-events', 'none'); + + // Create hidden cancel zone (shown when spring-load activates) + const $cancelZone = $(``); + _this.$el_window.find('.dashboard-section-files').append($cancelZone); + $cancelZone.droppable({ + accept: '.row', + tolerance: 'pointer', + over: function () { + $(this).addClass('drag-cancel-hover'); + }, + out: function () { + $(this).removeClass('drag-cancel-hover'); + }, + drop: function (_event, ui) { + ui.helper.data('dropped', true); + ui.helper.data('cancelled', true); + }, + }); + }, + + drag: function (event, ui) { + // Show helpers after 5px movement + if ( Math.abs(ui.originalPosition.top - ui.offset.top) > 5 || + Math.abs(ui.originalPosition.left - ui.offset.left) > 5 ) { + ui.helper.show(); + $('.item-selected-clone').show(); + $('.draggable-count-badge').show(); + } + + $('.draggable-count-badge').css({ + top: event.pageY, + left: event.pageX + 10, + }); + + $('.item-selected-clone').each(function (i) { + $(this).css({ + left: ui.position.left + 3 * (i + 1), + top: ui.position.top + 3 * (i + 1), + 'z-index': 999 - i, + 'opacity': 0.5 - i * 0.1, + }); + }); + }, + + stop: function (event, ui) { + const _this = TabFiles; + + // Clean up dwell state from any folder we were hovering over + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + $('.dwell-opening').removeClass('dwell-opening'); + + // Handle spring-loaded folder drag resolution + if ( _this.springLoadedActive ) { + if ( ui.helper.data('cancelled') ) { + // Dropped on cancel zone → navigate back, no move + _this.navigateBackFromSpringLoad(); + } else if ( ! ui.helper.data('dropped') ) { + // Not dropped on a specific target — check if within .files area + const filesEl = _this.$el_window.find('.files')[0]; + const rect = filesEl.getBoundingClientRect(); + const inFiles = event.clientX >= rect.left && event.clientX <= rect.right && + event.clientY >= rect.top && event.clientY <= rect.bottom; + + if ( inFiles ) { + // Dropped in file list but not on a folder → move to current dir + const itemsToMove = [el_item]; + $('.item-selected-clone').find('.row').each(function () { + itemsToMove.push(this); + }); + + if ( event.ctrlKey ) { + window.copy_items(itemsToMove, _this.currentPath); + } + else if ( event.altKey && window.feature_flags?.create_shortcut ) { + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + window.create_shortcut(itemName, isDir, _this.currentPath, null, shortcutTo, shortcutToPath); + } + } + else { + window.move_items(itemsToMove, _this.currentPath); + } + } else { + // Dropped outside file list → cancel, navigate back + _this.navigateBackFromSpringLoad(); + } + } + // If dropped on a specific folder/breadcrumb target, the drop + // handler already processed it — nothing to do here. + } + + _this.springLoadedActive = false; + _this.springLoadedOriginalPath = null; + $('.drag-cancel-zone').remove(); + $('.item-selected-clone').remove(); + $('.draggable-count-badge').remove(); + window.an_item_is_being_dragged = false; + $('.window-app-iframe').css('pointer-events', 'auto'); + }, + }); + + if ( file.is_dir ) { + $(el_item).droppable({ + accept: '.row', + tolerance: 'pointer', + + drop: async function (event, ui) { + const _this = TabFiles; + + // Clear dwell timer to prevent folder from opening after drop + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + + const draggedPath = $(ui.draggable).attr('data-path'); + if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + ui.helper.data('dropped', true); + + const itemsToMove = [ui.draggable[0]]; + + $('.item-selected-clone').each(function () { + const sourceId = $(this).attr('data-id'); + const sourceItem = document.querySelector(`.row[data-id="${sourceId}"]`); + if ( sourceItem ) itemsToMove.push(sourceItem); + }); + + const targetPath = $(el_item).attr('data-path'); + + if ( event.ctrlKey ) { + // Copy + await window.copy_items(itemsToMove, targetPath); + } + else if ( event.altKey && window.feature_flags?.create_shortcut ) { + // Create shortcuts + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + + await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath); + } + } + else { + await window.move_items(itemsToMove, targetPath); + } + }, + + over: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(el_item).addClass('selected'); + + const _this = TabFiles; + const targetPath = $(el_item).attr('data-path'); + + // Don't auto-open the current directory or trash + if ( targetPath === _this.currentPath || + targetPath === window.trash_path || + targetPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + // Clear any existing dwell timer + clearTimeout(_this.folderDwellTimer); + + // Add visual feedback animation + $(el_item).addClass('dwell-opening'); + _this.folderDwellTarget = el_item; + + // Start dwell timer — navigate into folder after 700ms + _this.folderDwellTimer = setTimeout(async () => { + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + if ( ! _this.springLoadedActive ) { + _this.springLoadedOriginalPath = _this.currentPath; + } + _this.springLoadedActive = true; + $('.drag-cancel-zone').show(); + $(el_item).removeClass('dwell-opening selected'); + + _this.pushNavHistory(targetPath); + _this.renderDirectory(targetPath); + + // Refresh jQuery UI droppable detection for the active drag + if ( $.ui.ddmanager && $.ui.ddmanager.current ) { + $.ui.ddmanager.current.helper.addClass('ui-draggable-dragging'); + $.ui.ddmanager.prepareOffsets($.ui.ddmanager.current); + } + }, 700); + } + }, + + out: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(el_item).removeClass('selected dwell-opening'); + + const _this = TabFiles; + if ( _this.folderDwellTarget === el_item ) { + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + } + } + }, + }); + + // Add native file drop support to folder rows + $(el_item).dragster({ + enter: function (_dragsterEvent, event) { + const e = event.originalEvent; + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const targetPath = $(el_item).attr('data-path'); + + // Don't allow drop on trash folder + if ( targetPath === window.trash_path || + targetPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + $(el_item).addClass('native-drop-target'); + }, + + leave: function (_dragsterEvent, _event) { + $(el_item).removeClass('native-drop-target'); + }, + + drop: async function (_dragsterEvent, event) { + const e = event.originalEvent; + $(el_item).removeClass('native-drop-target'); + + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const targetPath = $(el_item).attr('data-path'); + + // Block uploads to trash + if ( targetPath === window.trash_path || + targetPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + if ( e.dataTransfer?.items?.length > 0 ) { + TabFiles.uploadFiles(e.dataTransfer.items, targetPath); + } + + e.stopPropagation(); + e.preventDefault(); + return false; + }, + }); + } + }, + + /** + * Restores a trashed item to its original location. + * + * This is a simplified restore function for the dashboard that calls + * puter.fs.move() directly, avoiding the complexity of window.move_items() + * which is designed for the desktop window system. + * + * @param {HTMLElement} el_item - The row element representing the trashed item + * @returns {Promise} The result from puter.fs.move() + */ + async restoreItem (el_item) { + const uid = $(el_item).attr('data-uid'); + const metadataStr = $(el_item).attr('data-metadata'); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + + if ( ! metadata.original_path ) { + throw new Error('Cannot restore: original path not found in metadata'); + } + + const destPath = path.dirname(metadata.original_path); + const originalName = metadata.original_name; + + const resp = await puter.fs.move({ + source: uid, + destination: destPath, + newName: originalName, + newMetadata: {}, + createMissingParents: true, + }); + + return resp; + }, + + /** + * Moves clipboard items to the specified destination path. + * + * This is a Dashboard-specific implementation that calls puter.fs.move() + * directly, bypassing window.move_clipboard_items() which relies on + * .item DOM elements that don't exist in the Dashboard. + * + * @param {string} destPath - The destination folder path + * @returns {Promise} + */ + async moveClipboardItems (destPath) { + if ( !window.clipboard || window.clipboard.length === 0 ) { + return; + } + + for ( const item of window.clipboard ) { + // Handle both object format { path, uid } and legacy string format + const source = item.uid || item.path || item; + try { + await puter.fs.move({ + source: source, + destination: destPath, + }); + } catch ( err ) { + console.error('Failed to move item:', err); + } + } + + window.clipboard = []; + }, + + /** + * Formats a byte count into a human-readable size string. + * + * @param {number} bytes - The size in bytes + * @returns {string} Formatted size string (e.g., "1.5 MB") + */ + formatFileSize (bytes) { + if ( bytes === 0 ) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100 } ${ sizes[i]}`; + }, + + /** + * Calculates the total size of files represented by row elements. + * + * @param {Array} rows - Array of row DOM elements with data-size attributes + * @returns {number} Total size in bytes + */ + calculateTotalSize (rows) { + let total = 0; + rows.forEach(row => { + const size = parseInt($(row).attr('data-size')) || 0; + total += size; + }); + return total; + }, + + /** + * Updates the footer status bar with item counts and sizes. + * + * Shows total item count and size, plus selected item count and size if any. + * + * @returns {void} + */ + updateFooterStats () { + const $footer = this.$el_window.find('.files-footer'); + const $selectionActions = this.$el_window.find('.files-selection-actions'); + if ( ! $footer.length ) return; + + const allRows = this.$el_window.find('.files-tab .row').toArray(); + const selectedRows = this.$el_window.find('.files-tab .row.selected').toArray(); + + const totalCount = allRows.length; + const selectedCount = selectedRows.length; + + const totalSize = this.calculateTotalSize(allRows); + const selectedSize = this.calculateTotalSize(selectedRows); + + const itemText = totalCount === 1 ? 'item' : 'items'; + $footer.find('.files-footer-item-count').html( + `${totalCount} ${itemText} · ${window.byte_format(totalSize)}`); + + if ( selectedCount > 0 ) { + const selectedItemText = selectedCount === 1 ? 'item' : 'items'; + $footer.find('.files-footer-selected-items') + .html(`${selectedCount} ${selectedItemText} selected · ${window.byte_format(selectedSize)}`) + .css('display', 'inline'); + $footer.find('.files-footer-separator').css('display', 'inline'); + } else { + $footer.find('.files-footer-selected-items').css('display', 'none'); + $footer.find('.files-footer-separator').css('display', 'none'); + } + + // Show/hide floating action bar based on selection count + // In mobile select mode, show with 1+ items; otherwise require 2+ + const isMobileSelectMode = (window.isMobile.phone || window.isMobile.tablet) && this.selectModeActive; + const minCountForActionBar = isMobileSelectMode ? 1 : 2; + + if ( selectedCount >= minCountForActionBar ) { + $selectionActions.addClass('visible'); + this.updateSelectionActionsState(selectedRows); + } else { + $selectionActions.removeClass('visible'); + } + }, + + /** + * Toggles between list and grid view modes. + * + * Persists the preference to storage. + * + * @returns {void} + */ + toggleView () { + const $filesContainer = this.$el_window.find('.files-tab .files'); + const $toggleBtn = this.$el_window.find('.view-toggle-btn'); + const $tabContent = this.$el_window.find('.files-tab'); + + if ( this.currentView === 'list' ) { + this.currentView = 'grid'; + $filesContainer.removeClass('files-list-view').addClass('files-grid-view'); + $tabContent.addClass('files-grid-mode'); + $toggleBtn.html(icons.list); + $toggleBtn.attr('title', 'Switch to list view'); + } else { + this.currentView = 'list'; + $filesContainer.removeClass('files-grid-view').addClass('files-list-view'); + $tabContent.removeClass('files-grid-mode'); + $toggleBtn.html(icons.grid); + $toggleBtn.attr('title', 'Switch to grid view'); + } + + puter.kv.set('view_mode', this.currentView); + + // Refresh content to update icons for the new view mode + if ( this.currentPath ) { + this.renderDirectory(this.currentPath); + } + }, + + /** + * Toggles select mode for mobile multi-file selection. + * + * When active, tapping files toggles their selection instead of opening them. + * Checkboxes appear next to each item for visual feedback. + * + * @returns {void} + */ + toggleSelectMode () { + this.selectModeActive = !this.selectModeActive; + const $filesTab = this.$el_window.find('.files-tab'); + const $selectBtn = this.$el_window.find('.select-mode-btn'); + + if ( this.selectModeActive ) { + $filesTab.addClass('select-mode-active'); + $selectBtn.addClass('active'); + } else { + $filesTab.removeClass('select-mode-active'); + $selectBtn.removeClass('active'); + // Clear all selections when exiting select mode + this.$el_window.find('.files .row.selected').removeClass('selected'); + this.updateFooterStats(); + } + }, + + /** + * Exits select mode and clears selections. + * + * @returns {void} + */ + exitSelectMode () { + if ( this.selectModeActive ) { + this.selectModeActive = false; + const $filesTab = this.$el_window.find('.files-tab'); + const $selectBtn = this.$el_window.find('.select-mode-btn'); + $filesTab.removeClass('select-mode-active'); + $selectBtn.removeClass('active'); + // Clear all selections + this.$el_window.find('.files .row.selected').removeClass('selected'); + this.updateFooterStats(); + } + }, + + /** + * Navigates back to the original folder after cancelling a spring-loaded drag. + * Walks back through nav history to find the original path position. + * + * @returns {void} + */ + navigateBackFromSpringLoad () { + if ( ! this.springLoadedOriginalPath ) return; + + // Walk back through nav history to find the original path + for ( let i = window.dashboard_nav_history_current_position - 1; i >= 0; i-- ) { + if ( window.dashboard_nav_history[i] === this.springLoadedOriginalPath ) { + window.dashboard_nav_history_current_position = i; + this.renderDirectory(this.springLoadedOriginalPath); + return; + } + } + // Fallback: render the original path directly + this.renderDirectory(this.springLoadedOriginalPath); + }, + + /** + * Initializes the navigation history with a starting path. + * + * @param {string} initialPath - The initial directory path + * @returns {void} + */ + initNavHistory (initialPath) { + window.dashboard_nav_history = [initialPath]; + window.dashboard_nav_history_current_position = 0; + this.updateNavButtonStates(); + }, + + /** + * Pushes a new path onto the navigation history stack. + * + * Truncates any forward history when navigating to a new location. + * + * @param {string} newPath - The path to add to history + * @returns {void} + */ + pushNavHistory (newPath) { + // If history is empty, initialize with this path + if ( window.dashboard_nav_history.length === 0 ) { + window.dashboard_nav_history = [newPath]; + window.dashboard_nav_history_current_position = 0; + } else { + // Truncate forward history when navigating to new location + window.dashboard_nav_history = window.dashboard_nav_history.slice(0, window.dashboard_nav_history_current_position + 1); + window.dashboard_nav_history.push(newPath); + window.dashboard_nav_history_current_position++; + } + this.updateNavButtonStates(); + }, + + /** + * Updates the enabled/disabled state of navigation buttons. + * + * Disables back button at history start, forward button at history end, + * and up button at root directory. + * + * @returns {void} + */ + updateNavButtonStates () { + if ( ! this.$el_window ) return; + + const backBtn = this.$el_window.find('.path-btn-back'); + const forwardBtn = this.$el_window.find('.path-btn-forward'); + const upBtn = this.$el_window.find('.path-btn-up'); + + if ( window.dashboard_nav_history_current_position === 0 ) { + backBtn.addClass('path-btn-disabled'); + } else { + backBtn.removeClass('path-btn-disabled'); + } + + if ( window.dashboard_nav_history_current_position >= window.dashboard_nav_history.length - 1 ) { + forwardBtn.addClass('path-btn-disabled'); + } else { + forwardBtn.removeClass('path-btn-disabled'); + } + + if ( this.currentPath === '/' ) { + upBtn.addClass('path-btn-disabled'); + } else { + upBtn.removeClass('path-btn-disabled'); + } + }, + + /** + * Updates the browser URL hash to reflect the current file path in Dashboard. + * + * @param {string} filePath - The current file system path (e.g., /username/Documents) + * @returns {void} + */ + updateDashboardUrl (filePath) { + // Use pushState to update URL without firing hashchange. + // The popstate listener in UIDashboard handles back/forward navigation. + const newHash = `#files${filePath}`; + if ( window.location.hash !== newHash ) { + history.pushState(null, '', newHash); + } + }, + + /** + * Handles click on the "more" button (three dots) for a file row. + * + * Shows appropriate context menu for single or multi-selection. + * + * @param {HTMLElement} rowElement - The row element that was clicked + * @param {Object} file - The file/folder object data + * @returns {Promise} + */ + async handleMoreClick (rowElement, file, targetElement) { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + + let items; + if ( selectedRows.length > 1 && rowElement.classList.contains('selected') ) { + items = await this.generateMultiSelectContextMenu(selectedRows); + } + else { + items = await this.generateContextMenuItems(rowElement, file); + } + + // Use mobile-friendly context menu on touch devices + if ( window.isMobile.phone || window.isMobile.tablet ) { + const targetRect = targetElement.getBoundingClientRect(); + const modal = new ContextMenuModal(); + modal.show(items, targetRect); + } else { + UIContextMenu({ items: items }); + } + }, + + /** + * Generates context menu items for a single file/folder. + * + * @param {HTMLElement} el_item - The row DOM element + * @param {Object} options - The file/folder object with metadata + * @returns {Promise} Array of menu item objects + */ + async generateContextMenuItems (el_item, options) { + const _this = this; + + const is_trash = $(el_item).attr('data-path') === window.trash_path || $(el_item).attr('data-shortcut_to_path') === window.trash_path; + const is_trashed = ($(el_item).attr('data-path') || '').startsWith(`${window.trash_path }/`); + const is_worker = $(el_item).attr('data-is_worker') === "1"; + + const menu_items = await generate_file_context_menu({ + element: el_item, + fsentry: options, + is_trash, + is_trashed, + is_worker, + suggested_apps: options.suggested_apps, + associated_app_name: options.associated_app_name, + onRestore: async (el) => { + await _this.restoreItem(el); + $(el).fadeOut(150, function () { + $(this).remove(); + }); + _this.updateFooterStats(); + }, + onOpen: (el, fsentry) => { + // Custom open handler for Dashboard (avoids window_nav_history issues) + if ( fsentry.is_dir ) { + _this.pushNavHistory(fsentry.path); + _this.renderDirectory(fsentry.path); + } else { + open_item({ item: el }); + } + }, + }); + + return menu_items; + }, + + /** + * Generates context menu items for multiple selected files/folders. + * + * Provides bulk operations like download, cut, copy, and delete. + * + * @param {NodeList|Array} selectedRows - The selected row elements + * @returns {Promise} Array of menu item objects + */ + async generateMultiSelectContextMenu (selectedRows) { + const _this = this; + const items = []; + + // Check if any are trashed + const anyTrashed = Array.from(selectedRows).some(row => { + const path = $(row).attr('data-path'); + return path?.startsWith(`${window.trash_path}/`); + }); + + if ( anyTrashed ) { + items.push({ + html: i18n('restore'), + onClick: async function () { + for ( const row of selectedRows ) { + try { + await _this.restoreItem(row); + $(row).fadeOut(150, function () { + $(this).remove(); + }); + } catch ( err ) { + console.error('Failed to restore item:', err); + } + } + _this.updateFooterStats(); + }, + }); + items.push('-'); + } + + if ( ! anyTrashed ) { + items.push({ + html: `${i18n('download')}`, + onClick: function () { + window.zipItems(Array.from(selectedRows), _this.currentPath, true); + }, + }); + items.push('-'); + } + + // Cut + items.push({ + html: `${i18n('cut')}`, + onClick: function () { + window.clipboard_op = 'move'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ + path: $(row).attr('data-path'), + uid: $(row).attr('data-uid'), + }); + }); + }, + }); + + // Copy + if ( ! anyTrashed ) { + items.push({ + html: `${i18n('copy')}`, + onClick: function () { + window.clipboard_op = 'copy'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ path: $(row).attr('data-path') }); + }); + }, + }); + } + + items.push('-'); + + // Delete + if ( anyTrashed ) { + items.push({ + html: i18n('delete_permanently'), + onClick: async function () { + const confirmed = await UIAlert({ + message: i18n('confirm_delete_multiple_items'), + buttons: [ + { label: i18n('delete'), type: 'primary' }, + { label: i18n('cancel') }, + ], + }); + if ( confirmed === 'Delete' ) { + for ( const row of selectedRows ) { + await window.delete_item(row); + } + } + }, + }); + } + else { + items.push({ + html: `${i18n('delete')}`, + onClick: function () { + window.move_items(Array.from(selectedRows), window.trash_path); + }, + }); + } + + return items; + }, + + /** + * Generates context menu items for folder background (empty area). + * + * Includes options for new folder/file, paste, upload, refresh, etc. + * + * @param {string} [folderPath] - The folder path, defaults to current path + * @returns {Array} Array of menu item objects + */ + generateFolderContextMenu (folderPath) { + const _this = this; + const targetPath = folderPath || this.currentPath; + + if ( ! targetPath ) return []; + + const isTrashFolder = targetPath === window.trash_path; + const items = []; + + // New submenu (folder, text document, etc.) - not available in Trash + // We create a custom "New" submenu to handle folder creation with refresh and rename activation + if ( ! isTrashFolder ) { + const newMenuItems = new_context_menu_item(targetPath, null); + + // Override the "New Folder" onClick to refresh and activate rename + if ( newMenuItems.items && newMenuItems.items.length > 0 ) { + const folderItem = newMenuItems.items[0]; // First item is "New Folder" + folderItem.onClick = async () => { + $('.context-menu').remove(); + _this._creatingItem = true; + try { + const result = await puter.fs.mkdir({ + path: `${targetPath}/New Folder`, + rename: true, + overwrite: false, + }); + // Remove empty-directory placeholder if present + _this.$el_window.find('.files-tab .files > div:not(.item)').remove(); + // Add the new folder incrementally + await _this.renderItem(result); + const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`); + if ( $newRow.length > 0 ) { + _this.insertAtSortedPosition($newRow, result); + _this.applyColumnWidths(); + _this.updateFooterStats(); + $newRow.addClass('selected'); + window.activate_item_name_editor($newRow[0]); + } + } catch ( err ) { + // Folder creation failed silently + } finally { + _this._creatingItem = false; + } + }; + + // Override other file creation items to intercept create_file, + // refresh directory, and activate rename mode + const wrapWithDashboardRename = (originalOnClick) => { + return async () => { + $('.context-menu').remove(); + _this._creatingItem = true; + + // Temporarily intercept create_file to capture the upload promise + let uploadPromise = null; + const origCreateFile = window.create_file; + window.create_file = (options) => { + const content = options.content ? [options.content] : []; + uploadPromise = puter.fs.upload(new File(content, options.name), options.dirname); + return uploadPromise; + }; + + try { + await originalOnClick(); + + // For callback-based creation (e.g., canvas.toBlob), wait briefly + if ( ! uploadPromise ) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + + if ( uploadPromise ) { + const result = await uploadPromise; + // Remove empty-directory placeholder if present + _this.$el_window.find('.files-tab .files > div:not(.item)').remove(); + // Add the new file incrementally + await _this.renderItem(result); + const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`); + if ( $newRow.length > 0 ) { + _this.insertAtSortedPosition($newRow, result); + _this.applyColumnWidths(); + _this.updateFooterStats(); + $newRow.addClass('selected'); + window.activate_item_name_editor($newRow[0]); + } + } + } catch ( err ) { + // File creation failed silently + } finally { + window.create_file = origCreateFile; + _this._creatingItem = false; + } + }; + }; + + for ( let i = 2; i < newMenuItems.items.length; i++ ) { + const item = newMenuItems.items[i]; + if ( !item || typeof item === 'string' ) continue; + if ( item.onClick ) { + item.onClick = wrapWithDashboardRename(item.onClick); + } + // Handle nested submenu items (user templates) + if ( item.items && Array.isArray(item.items) ) { + for ( const subItem of item.items ) { + if ( subItem && subItem.onClick ) { + subItem.onClick = wrapWithDashboardRename(subItem.onClick); + } + } + } + } + } + + items.push(newMenuItems); + items.push('-'); + } + + // Paste - only if clipboard has items and not in Trash + if ( !isTrashFolder && window.clipboard && window.clipboard.length > 0 ) { + items.push({ + html: i18n('paste'), + onClick: async function () { + if ( window.clipboard_op === 'copy' ) { + window.copy_clipboard_items(targetPath, null); + } else if ( window.clipboard_op === 'move' ) { + await _this.moveClipboardItems(targetPath); + } + }, + }); + } + + // Undo - if there are actions to undo + if ( window.actions_history && window.actions_history.length > 0 ) { + items.push({ + html: i18n('undo'), + onClick: function () { + window.undo_last_action(); + }, + }); + } + + // Add separator if we added paste or undo + if ( items.length > 2 || (isTrashFolder && items.length > 0) ) { + items.push('-'); + } + + // Upload Here - not available in Trash + if ( ! isTrashFolder ) { + items.push({ + html: i18n('upload'), + onClick: function () { + const fileInput = document.querySelector('#upload-file-dialog'); + if ( fileInput ) { + fileInput.click(); + } + }, + }); + } + + // Refresh + items.push({ + html: i18n('refresh'), + onClick: function () { + _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + }, + }); + + // Empty Trash - only in Trash folder + if ( isTrashFolder ) { + items.push('-'); + items.push({ + html: i18n('empty_trash'), + onClick: function () { + window.empty_trash(); + }, + }); + } + + return items; + }, + + /** + * Initializes rubber band (drag-to-select) selection for the files container. + * + * Uses the viselect library to enable drag selection in both list and grid views. + * Only activates when dragging from empty space, not from file/folder items. + * + * @returns {void} + */ + initRubberBandSelection () { + const _this = this; + + // Skip on mobile/touch devices + if ( window.isMobile.phone || window.isMobile.tablet ) { + return; + } + + let selected_ctrl_items = []; + let selection_area = null; + let selection_area_start_x = 0; + let selection_area_start_y = 0; + let initial_container_scroll_width = 0; + let initial_container_scroll_height = 0; + + const filesContainer = this.$el_window.find('.files-tab .files')[0]; + if ( ! filesContainer ) return; + + const containerId = `tabfiles-container-${Date.now()}`; + filesContainer.id = containerId; + + const selection = new SelectionArea({ + selectionContainerClass: 'selection-area-container', + selectionAreaClass: 'hidden-selection-area', + container: `#${containerId}`, + selectables: [`#${containerId} .row`], + startareas: [`#${containerId}`], + boundaries: [`#${containerId}`], + behaviour: { + overlap: 'drop', + intersect: 'touch', + startThreshold: 10, + scrolling: { + speedDivider: 10, + manualSpeed: 750, + startScrollMargins: { x: 0, y: 0 }, + }, + }, + features: { + touch: false, + range: true, + singleTap: { + allow: false, + intersect: 'native', + }, + }, + }); + + this.rubberBandSelection = selection; + + selection.on('beforestart', ({ event }) => { + selected_ctrl_items = []; + + // Block rubberband when starting from an already-selected item + // (so that file dragging can take over instead). + const targetRow = $(event.target).closest('.row:not(.header)'); + if ( targetRow.length && targetRow.hasClass('selected') ) { + return false; + } + + // Block rubberband when starting from item drag handles so item drag takes over + if ( $(event.target).closest('.item-name, .item-icon, .item-badges').length ) { + return false; + } + + // Capture starting position (element created later in 'start' event) + const scrollLeft = $(filesContainer).scrollLeft(); + const scrollTop = $(filesContainer).scrollTop(); + const containerRect = filesContainer.getBoundingClientRect(); + + initial_container_scroll_width = filesContainer.scrollWidth; + initial_container_scroll_height = filesContainer.scrollHeight; + + let relativeX = event.clientX - containerRect.left + scrollLeft; + let relativeY = event.clientY - containerRect.top + scrollTop; + + relativeX = Math.max(0, Math.min(initial_container_scroll_width, relativeX)); + relativeY = Math.max(0, Math.min(initial_container_scroll_height, relativeY)); + + selection_area_start_x = relativeX; + selection_area_start_y = relativeY; + + return true; + }); + + selection.on('start', ({ store, event }) => { + if ( !event.ctrlKey && !event.metaKey ) { + for ( const el of store.stored ) { + el.classList.remove('selected'); + } + selection.clearSelection(); + } + + // Disable pointer events on selection actions bar during drag + _this.$el_window.find('.files-selection-actions').addClass('rubberband-active'); + + // Create selection area element only when drag actually starts (after threshold) + selection_area = document.createElement('div'); + $(filesContainer).append(selection_area); + $(selection_area).addClass('tabfiles-selection-area'); + $(selection_area).css({ + position: 'absolute', + top: selection_area_start_y, + left: selection_area_start_x, + width: 0, + height: 0, + zIndex: 1000, + display: 'block', + }); + }); + + selection.on('move', ({ store: { changed: { added, removed } }, event }) => { + // Skip if no event (can happen during programmatic moves) + if ( ! event ) return; + + const scrollLeft = $(filesContainer).scrollLeft(); + const scrollTop = $(filesContainer).scrollTop(); + const containerRect = filesContainer.getBoundingClientRect(); + + let currentMouseX = event.clientX - containerRect.left + scrollLeft; + let currentMouseY = event.clientY - containerRect.top + scrollTop; + + const constrainedMouseX = Math.max(0, Math.min(filesContainer.scrollWidth, currentMouseX)); + const constrainedMouseY = Math.max(0, Math.min(filesContainer.scrollHeight, currentMouseY)); + + const width = Math.abs(constrainedMouseX - selection_area_start_x); + const height = Math.abs(constrainedMouseY - selection_area_start_y); + const left = Math.min(constrainedMouseX, selection_area_start_x); + const top = Math.min(constrainedMouseY, selection_area_start_y); + + $(selection_area).css({ width, height, left, top }); + + for ( const el of added ) { + if ( (event.ctrlKey || event.metaKey) && $(el).hasClass('selected') ) { + el.classList.remove('selected'); + selected_ctrl_items.push(el); + } else { + el.classList.add('selected'); + window.active_element = el; + window.latest_selected_item = el; + } + } + + for ( const el of removed ) { + el.classList.remove('selected'); + if ( selected_ctrl_items.includes(el) ) { + $(el).addClass('selected'); + } + } + + _this.updateFooterStats(); + }); + + selection.on('stop', () => { + if ( selection_area ) { + $(selection_area).remove(); + selection_area = null; + // Flag to prevent the click handler from clearing selection + _this.rubberBandSelectionJustEnded = true; + } + // Re-enable pointer events on selection actions bar + _this.$el_window.find('.files-selection-actions').removeClass('rubberband-active'); + _this.updateFooterStats(); + }); + }, + + /** + * Initializes native file drag-and-drop upload support. + * + * Sets up dragster on the main files container to allow dropping + * local files for upload. Sidebar folders and folder rows get their + * dragster initialized in init() and createItemListeners() respectively. + * + * @returns {void} + */ + initNativeFileDrop () { + this.initContentAreaDragster(); + }, + + /** + * Initializes dragster on the main files content area. + * + * Dropping files here uploads them to the current directory (this.currentPath). + * Only responds to native file drags (from OS), not internal item drags. + * + * @returns {void} + */ + initContentAreaDragster () { + const _this = this; + const $filesContainer = this.$el_window.find('.files-tab .files'); + + $filesContainer.dragster({ + enter: function (_dragsterEvent, event) { + const e = event.originalEvent; + // Only respond to native file drags, not internal item drags + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + // Don't show drop zone if we're in trash + if ( _this.currentPath === window.trash_path ) { + return; + } + + // Remove any context menus + $('.context-menu').remove(); + + // Add visual drop zone indicator + $filesContainer.addClass('native-drop-active'); + }, + + leave: function (_dragsterEvent, _event) { + $filesContainer.removeClass('native-drop-active'); + }, + + drop: async function (_dragsterEvent, event) { + const e = event.originalEvent; + $filesContainer.removeClass('native-drop-active'); + + // Only handle native file drops + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + // Skip if drop was on a subfolder (check if target is inside a folder row) + const $target = $(e.target); + const $folderRow = $target.closest('.row.folder'); + if ( $folderRow.length > 0 ) { + // Drop was on a folder row, let it handle the upload + return; + } + + // Block uploads to trash + if ( _this.currentPath === window.trash_path ) { + return; + } + + // Upload the dropped files + if ( e.dataTransfer?.items?.length > 0 ) { + _this.uploadFiles(e.dataTransfer.items, _this.currentPath); + } + + e.stopPropagation(); + e.preventDefault(); + return false; + }, + }); + }, + + /** + * Uploads files to the specified destination path. + * + * This method handles the complete upload flow including progress modal, + * error handling, and directory refresh on completion. Used by drag-drop + * upload handlers to ensure the Dashboard view updates after uploads. + * + * @param {DataTransferItemList|FileList} items - The files to upload + * @param {string} destPath - The destination directory path + * @returns {void} + */ + uploadFiles (items, destPath) { + const _this = this; + let upload_progress_window; + let opid; + + if ( destPath === window.trash_path ) { + UIAlert('Uploading to trash is not allowed!'); + return; + } + + puter.fs.upload(items, destPath, { + generateThumbnails: true, + init: async (operation_id, xhr) => { + opid = operation_id; + upload_progress_window = await UIWindowProgress({ + title: i18n('upload'), + icon: window.icons['app-icon-uploader.svg'], + operation_id: operation_id, + show_progress: true, + on_cancel: () => { + window.show_save_account_notice_if_needed(); + xhr.abort(); + }, + }); + window.active_uploads[opid] = 0; + }, + start: async function () { + upload_progress_window.set_status('Uploading'); + upload_progress_window.set_progress(0); + }, + progress: async function (_operation_id, op_progress) { + upload_progress_window.set_progress(op_progress); + window.active_uploads[opid] = op_progress; + if ( document.visibilityState !== 'visible' ) { + update_title_based_on_uploads(); + } + }, + success: function (items) { + const files = []; + if ( typeof items[Symbol.iterator] === 'function' ) { + for ( const item of items ) { + files.push(item.path); + } + } else { + files.push(items.path); + } + window.actions_history.push({ + operation: 'upload', + data: files, + }); + setTimeout(() => { + upload_progress_window.close(); + }, 1000); + window.show_save_account_notice_if_needed(); + delete window.active_uploads[opid]; + // Refresh directory to show uploaded files + _this.renderDirectory(_this.currentPath); + }, + error: async function (err) { + upload_progress_window.show_error(i18n('error_uploading_files'), err.message); + delete window.active_uploads[opid]; + }, + abort: async function (_operation_id) { + delete window.active_uploads[opid]; + }, + }); + }, + + /** + * Renders the breadcrumb path navigation HTML. + * + * Creates clickable path segments with separators. + * + * @param {string} abs_path - The absolute path to render + * @returns {string} HTML string for the breadcrumb navigation + */ + renderPath (abs_path) { + const { html_encode } = window; + // remove trailing slash + if ( abs_path.endsWith('/') && abs_path !== '/' ) { + abs_path = abs_path.slice(0, -1); + } + + const dirs = (abs_path === '/' ? [''] : abs_path.split('/')); + const dirpaths = (abs_path === '/' ? ['/'] : []); + const path_seperator_html = ``; + if ( dirs.length > 1 ) { + for ( let i = 0; i < dirs.length; i++ ) { + dirpaths[i] = ''; + for ( let j = 1; j <= i; j++ ) { + dirpaths[i] += `/${dirs[j]}`; + } + } + } + let str = `${path_seperator_html}${html_encode(window.root_dirname)}`; + for ( let k = 1; k < dirs.length; k++ ) { + str += `${path_seperator_html}${dirs[k] === 'Trash' ? i18n('trash') : html_encode(dirs[k])}`; + } + return str; + }, + + /** + * + * Shows loading spinner over files section + */ + showSpinner () { + if ( this.loading ) return; + this.loading = true; + + const overlay = document.createElement('div'); + overlay.classList.add('files-loading-overlay'); + overlay.innerHTML = ` +
+
+
Working...
+
+ `; + + document.querySelector('.directory-contents .files').appendChild(overlay); + setTimeout(() => { + overlay.style.opacity = 1; + }, 100); + }, + + /** + * + * Hides the loading spinner over files section + */ + hideSpinner () { + const overlay = document.querySelector('.files-loading-overlay'); + if ( overlay ) { + overlay.parentNode?.removeChild(overlay); + } + this.loading = false; }, }; -export default TabFiles; +// Canvas context for measuring text width (reused for performance) +let measureContext = null; +/** + * Measures the pixel width of text using a canvas context. + * + * @param {string} text - The text to measure + * @param {string} font - CSS font string (e.g., '500 13px system-ui') + * @returns {number} Width in pixels + */ +function measureTextWidth (text, font = '500 13px system-ui, -apple-system, sans-serif') { + if ( ! measureContext ) { + const canvas = document.createElement('canvas'); + measureContext = canvas.getContext('2d'); + } + measureContext.font = font; + return measureContext.measureText(text).width; +} + +/** + * Truncates a filename in the middle to fit a given pixel width, preserving the extension. + * + * @param {string} filename - The full filename to truncate + * @param {number} maxWidth - Maximum width in pixels + * @param {string} font - CSS font string for measurement + * @returns {string} Truncated filename with ellipsis in middle, or original if it fits + */ +function truncateFilenameToWidth (filename, maxWidth, font = '500 13px system-ui, -apple-system, sans-serif') { + const fullWidth = measureTextWidth(filename, font); + if ( fullWidth <= maxWidth ) { + return filename; + } + + // Find extension + const lastDot = filename.lastIndexOf('.'); + const hasExtension = lastDot > 0 && lastDot < filename.length - 1; + const extension = hasExtension ? filename.slice(lastDot) : ''; + const baseName = hasExtension ? filename.slice(0, lastDot) : filename; + + const ellipsis = '…'; + const ellipsisWidth = measureTextWidth(ellipsis, font); + const extensionWidth = measureTextWidth(extension, font); + + // Available width for the base name (before and after ellipsis) + const availableWidth = maxWidth - ellipsisWidth - extensionWidth; + if ( availableWidth <= 0 ) { + return ellipsis + extension; + } + + // Binary search to find how many characters fit + // We want roughly equal parts before and after the ellipsis + const targetHalfWidth = availableWidth / 2; + + let startChars = 0; + let endChars = 0; + + // Find characters for start + for ( let i = 1; i <= baseName.length; i++ ) { + if ( measureTextWidth(baseName.slice(0, i), font) > targetHalfWidth ) { + startChars = i - 1; + break; + } + startChars = i; + } + + // Find characters for end (before extension) + for ( let i = 1; i <= baseName.length - startChars; i++ ) { + if ( measureTextWidth(baseName.slice(-i), font) > targetHalfWidth ) { + endChars = i - 1; + break; + } + endChars = i; + } + + if ( startChars === 0 && endChars === 0 ) { + return ellipsis + extension; + } + + const start = baseName.slice(0, startChars); + const end = endChars > 0 ? baseName.slice(-endChars) : ''; + + return start + ellipsis + end + extension; +} + +export default TabFiles; \ No newline at end of file diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 7b7eb413c..8c51e91a8 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -28,7 +28,7 @@ function getTimeGreeting () { function buildRecentAppsHTML () { let h = ''; - + if ( window.launch_apps?.recent?.length > 0 ) { h += '
'; @@ -42,10 +42,10 @@ function buildRecentAppsHTML () { } h += `
`; - // Icon - h += ``; - // Title - h += `${html_encode(app_info.title)}`; + // Icon + h += ``; + // Title + h += `${html_encode(app_info.title)}`; h += '
'; } h += '
'; @@ -59,57 +59,57 @@ function buildRecentAppsHTML () { h += 'Apps you use will appear here'; h += ''; } - + return h; } function buildUsageHTML () { let h = ''; h += '
'; - + // Your Plan section h += '
'; - h += ''; - h += `

${i18n('your_plan')}

`; - h += '›'; - h += '
'; - h += '
'; - h += '--'; - h += ''; - h += '
'; - h += ''; + h += ''; + h += `

${i18n('your_plan')}

`; + h += '›'; + h += '
'; + h += '
'; + h += '--'; + h += ''; h += '
'; - + h += ''; + h += '
'; + // Storage section h += '
'; - h += ''; - h += `

Your ${i18n('Storage')}

`; - h += '›'; - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += '-- Used'; - h += '--% of --'; - h += '
'; + h += ''; + h += `

Your ${i18n('Storage')}

`; + h += '›'; + h += '
'; + h += '
'; + h += '
'; h += '
'; - + h += '
'; + h += '-- Used'; + h += '--% of --'; + h += '
'; + h += '
'; + // Resources section h += '
'; - h += ''; - h += `

Your ${i18n('Resources')}

`; - h += '›'; - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += '-- Used'; - h += '--% of --'; - h += '
'; + h += ''; + h += `

Your ${i18n('Resources')}

`; + h += '›'; + h += '
'; + h += '
'; + h += '
'; h += '
'; - + h += '
'; + h += '-- Used'; + h += '--% of --'; + h += '
'; + h += '
'; + h += '
'; return h; } @@ -117,74 +117,74 @@ function buildUsageHTML () { const TabHome = { id: 'home', label: 'Home', - icon: ``, + icon: '', html () { const username = window.user?.username || 'User'; const greeting = getTimeGreeting(); const profilePicture = window.user?.profile?.picture || window.icons['profile.svg']; - + let h = ''; h += '
'; - + // Welcome card (square) h += '
'; - h += '
'; - h += '
'; - h += `
`; - h += `
`; - h += `${greeting},`; - h += `

${html_encode(username)}

`; - h += '

Your personal cloud computer

'; - // Show warning if account is temporary/unsaved - if ( window.user?.is_temp ) { - h += ''; - } - h += '
'; - h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += `
`; + h += `${greeting},`; + h += `

${html_encode(username)}

`; + h += '

Your personal cloud computer

'; + // Show warning if account is temporary/unsaved + if ( window.user?.is_temp ) { + h += ''; + } h += '
'; - + h += '
'; + h += '
'; + // Recent apps card (rectangle) h += '
'; - h += '
'; - h += '
'; - h += ''; - h += '
'; - h += '
'; - h += '

Apps

'; - h += ''; - h += ''; - h += 'Recently used'; - h += ''; - h += '
'; - h += '
'; - h += '
'; - h += buildRecentAppsHTML(); - h += '
'; + h += '
'; + h += '
'; + h += ''; h += '
'; - + h += '
'; + h += '

Apps

'; + h += ''; + h += ''; + h += 'Recently used'; + h += ''; + h += '
'; + h += '
'; + h += '
'; + h += buildRecentAppsHTML(); + h += '
'; + h += '
'; + // Usage card (spans full width on second row) h += '
'; - h += '
'; - h += '
'; - h += ''; - h += '
'; - h += '
'; - h += `

${i18n('usage')}

`; - h += ''; - h += ''; - h += 'Monthly overview'; - h += ''; - h += '
'; - h += '
'; - h += '
'; - h += buildUsageHTML(); - h += '
'; + h += '
'; + h += '
'; + h += ''; h += '
'; - + h += '
'; + h += `

${i18n('usage')}

`; + h += ''; + h += ''; + h += 'Monthly overview'; + h += ''; + h += '
'; + h += '
'; + h += '
'; + h += buildUsageHTML(); + h += '
'; + h += '
'; + h += '
'; return h; }, @@ -238,7 +238,7 @@ const TabHome = { }, async loadRecentApps ($el_window) { - if ( !window.launch_apps?.recent?.length ) { + if ( ! window.launch_apps?.recent?.length ) { try { window.launch_apps = await $.ajax({ url: `${window.api_origin}/get-launch-apps?icon_size=64`, @@ -260,12 +260,12 @@ const TabHome = { // Load plan data try { const hasSubscription = window.user?.subscription?.active; - const planName = hasSubscription + const planName = hasSubscription ? (window.user?.subscription?.plan_name || i18n('billing.offering.pro')) : i18n('billing.offering.free'); - + $el_window.find('.bento-plan-name').text(planName); - + if ( hasSubscription ) { $el_window.find('.bento-plan-badge').text('Active subscription').addClass('active'); $el_window.find('.bento-plan-upgrade').hide(); @@ -320,4 +320,3 @@ const TabHome = { }; export default TabHome; - diff --git a/src/gui/src/UI/Dashboard/TabSecurity.js b/src/gui/src/UI/Dashboard/TabSecurity.js index 9742db490..fbefd99ae 100644 --- a/src/gui/src/UI/Dashboard/TabSecurity.js +++ b/src/gui/src/UI/Dashboard/TabSecurity.js @@ -26,68 +26,68 @@ import UIWindowManageSessions from '../UIWindowManageSessions.js'; const TabSecurity = { id: 'security', label: i18n('security'), - icon: ``, + icon: '', html () { let h = ''; let user = window.user; h += '
'; - + // Section header h += '
'; - h += `

${i18n('security')}

`; - h += '

Manage your security settings and sessions

'; + h += `

${i18n('security')}

`; + h += '

Manage your security settings and sessions

'; h += '
'; // Security settings cards h += '
'; // Password card (only for non-temp users) - if ( !user.is_temp ) { + if ( ! user.is_temp ) { h += '
'; - h += '
'; - h += '
'; - h += ''; - h += '
'; - h += '
'; - h += `${i18n('password')}`; - h += '••••••••'; - h += '
'; - h += '
'; - h += ``; + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += '
'; + h += `${i18n('password')}`; + h += '••••••••'; + h += '
'; + h += '
'; + h += ``; h += '
'; } // Sessions card h += '
'; - h += '
'; - h += '
'; - h += ''; - h += '
'; - h += '
'; - h += `${i18n('sessions')}`; - h += 'Manage active sessions'; - h += '
'; - h += '
'; - h += ``; + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += '
'; + h += `${i18n('sessions')}`; + h += 'Manage active sessions'; + h += '
'; + h += '
'; + h += ``; h += '
'; // 2FA card (only for non-temp users with confirmed email) if ( !user.is_temp && user.email_confirmed ) { const twoFaStatusClass = user.otp ? 'dashboard-settings-card-success' : 'dashboard-settings-card-warning'; h += `
`; - h += '
'; - h += '
'; - h += ''; - h += '
'; - h += '
'; - h += `${i18n('two_factor')}`; - h += `${i18n(user.otp ? 'two_factor_enabled' : 'two_factor_disabled')}`; - h += '
'; - h += '
'; - h += ``; - h += ``; + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += '
'; + h += `${i18n('two_factor')}`; + h += `${i18n(user.otp ? 'two_factor_enabled' : 'two_factor_disabled')}`; + h += '
'; + h += '
'; + h += ``; + h += ``; h += '
'; } @@ -185,7 +185,7 @@ const TabSecurity = { h += '
'; h += '
'; h += ''; - h += ''; + h += ''; h += '
'; h += '
'; h += ``; @@ -200,7 +200,7 @@ const TabSecurity = { is_resizable: false, body_css: { width: 'initial', - 'background-color': 'rgb(245 247 249)', + 'background-color': 'var(--dashboard-input-background)', 'backdrop-filter': 'blur(3px)', padding: '20px', }, diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 3ea38cc0e..ddee60349 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -1,3 +1,5 @@ +/* eslint-disable no-invalid-this */ +/* eslint-disable @stylistic/indent */ /** * Copyright (C) 2024-present Puter Technologies Inc. * @@ -53,13 +55,22 @@ import TabSecurity from './TabSecurity.js'; const builtinTabs = [ TabHome, // TabApps, - // TabFiles, + TabFiles, TabUsage, TabAccount, TabSecurity, ]; +// Dynamically load dashboard CSS if not already loaded +if ( ! document.querySelector('link[href*="dashboard.css"]') ) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/css/dashboard.css'; + document.head.appendChild(link); +} + async function UIDashboard (options) { + // eslint-disable-next-line no-unused-vars options = options ?? {}; // Create mutable tabs array from built-in tabs @@ -71,12 +82,12 @@ async function UIDashboard (options) { let h = ''; h += '
'; - + // Mobile sidebar toggle h += ''; - + // Sidebar h += '
'; // Navigation items container @@ -84,20 +95,21 @@ async function UIDashboard (options) { for ( let i = 0; i < tabs.length; i++ ) { const tab = tabs[i]; const isActive = i === 0 ? ' active' : ''; - h += `
`; + const isBeta = tab.label === 'Files'; + h += `
`; h += tab.icon; h += tab.label; h += '
'; } h += '
'; - + // User options button at bottom h += '
'; - h += `
`; + h += '
'; h += `
`; - h += `${html_encode(window.user?.username || 'User')}`; - h += ``; - h += `
`; + h += `${window.html_encode(window.user?.username || 'User')}`; + h += ''; + h += '
'; h += '
'; h += '
'; @@ -133,6 +145,11 @@ async function UIDashboard (options) { const $el_window = $(el_window); + // Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it) + if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) { + window.dashboard_initial_file_path = window.dashboard_initial_route.path; + } + // Initialize all tabs for ( const tab of tabs ) { if ( tab.init ) { @@ -143,15 +160,225 @@ async function UIDashboard (options) { // Dispatch 'dashboard-ready' event for extensions window.dispatchEvent(new CustomEvent('dashboard-ready', { detail: { window: $el_window } })); + // ========================================================================= + // Socket initialization + // In dashboard mode, UIDesktop is never loaded, so we create the socket here. + // This runs inside the function (not at module level) to ensure window.gui_origin + // and window.auth_token are already set. + // ========================================================================= + window.socket = io(`${window.gui_origin}/`, { + auth: { + auth_token: window.auth_token, + }, + }); + + window.socket.on('error', (error) => { + console.error('Dashboard Socket Error:', error); + }); + + window.socket.on('connect', function () { + window.socket.emit('puter_is_actually_open'); + }); + + window.socket.on('reconnect', function () { + console.log('Dashboard Socket: Reconnected', window.socket.id); + }); + + window.socket.on('disconnect', () => { + console.log('Dashboard Socket: Disconnected'); + }); + + window.socket.on('reconnect_attempt', (attempt) => { + console.log('Dashboard Socket: Reconnection Attempt', attempt); + }); + + window.socket.on('reconnect_error', (error) => { + console.log('Dashboard Socket: Reconnection Error', error); + }); + + window.socket.on('reconnect_failed', () => { + console.log('Dashboard Socket: Reconnection Failed'); + }); + + // Upload/download progress tracking + window.socket.on('upload.progress', (msg) => { + if ( window.progress_tracker[msg.operation_id] ) { + window.progress_tracker[msg.operation_id].cloud_uploaded += msg.loaded_diff; + if ( window.progress_tracker[msg.operation_id][msg.item_upload_id] ) { + window.progress_tracker[msg.operation_id][msg.item_upload_id].cloud_uploaded = msg.loaded; + } + } + }); + + window.socket.on('download.progress', (msg) => { + if ( window.progress_tracker[msg.operation_id] ) { + if ( window.progress_tracker[msg.operation_id][msg.item_upload_id] ) { + window.progress_tracker[msg.operation_id][msg.item_upload_id].downloaded = msg.loaded; + window.progress_tracker[msg.operation_id][msg.item_upload_id].total = msg.total; + } + } + }); + + // Trash status updates + window.socket.on('trash.is_empty', async (msg) => { + // Update sidebar Trash icon + const trashIcon = msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg']; + $('.directories [data-folder=\'Trash\'] img').attr('src', trashIcon); + + // If currently viewing trash and it's empty, clear the file list + const dashboard = window.dashboard_object; + if ( msg.is_empty && dashboard && dashboard.currentPath === window.trash_path ) { + $('.files-tab .files').empty(); + } + }); + + // ========================================================================= + // Item event handlers + // Incremental DOM updates using UIDashboardFileItem for item creation and + // direct jQuery manipulation for removals/updates. Mirrors UIDesktop's + // approach but adapted for Dashboard's list-view structure. + // ========================================================================= + + window.socket.on('item.moved', async (resp) => { + if ( resp.original_client_socket_id === window.socket.id ) return; + + // Fade out old item from view + $(`.item[data-uid='${resp.uid}']`).fadeOut(150, function () { + $(this).remove(); + }); + + // Create new item at destination if user is viewing that directory + if ( window.UIDashboardFileItem ) { + window.UIDashboardFileItem(resp); + } + }); + + window.socket.on('item.removed', async (item) => { + if ( item.original_client_socket_id === window.socket.id ) return; + if ( item.descendants_only ) return; + + $(`.item[data-path='${html_encode(item.path)}']`).fadeOut(150, function () { + $(this).remove(); + }); + }); + + window.socket.on('item.renamed', async (item) => { + if ( item.original_client_socket_id === window.socket.id ) return; + + const $el = $(`.item[data-uid='${item.uid}']`); + if ( $el.length === 0 ) return; + + // Update data attributes + $el.attr('data-name', html_encode(item.name)); + $el.attr('data-path', html_encode(item.path)); + + // Update displayed name + $el.find('.item-name').text(item.name); + $el.find('.item-name-editor').val(item.name); + }); + + window.socket.on('item.updated', async (item) => { + if ( item.original_client_socket_id === window.socket.id ) return; + + const $el = $(`.item[data-uid='${item.uid}']`); + if ( $el.length === 0 ) return; + + // Update data attributes + $el.attr('data-name', html_encode(item.name)); + $el.attr('data-path', html_encode(item.path)); + $el.attr('data-size', item.size); + $el.attr('data-modified', item.modified); + $el.attr('data-type', html_encode(item.type)); + + // Update displayed name + $el.find('.item-name').text(item.name); + $el.find('.item-name-editor').val(item.name); + }); + + window.socket.on('item.added', async (item) => { + if ( _.isEmpty(item) ) return; + if ( item.original_client_socket_id === window.socket.id ) return; + + if ( window.UIDashboardFileItem ) { + window.UIDashboardFileItem(item); + } + }); + + // Apply initial route from URL - activate the correct tab + if ( window.dashboard_initial_route ) { + const route = window.dashboard_initial_route; + + // Activate the correct tab if not home + if ( route.tab && route.tab !== 'home' ) { + const tabId = route.tab; + const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tabId}"]`); + + // Only switch if the tab exists + if ( $targetTab.length > 0 ) { + $el_window.find('.dashboard-sidebar-item').removeClass('active'); + $targetTab.addClass('active'); + $el_window.find('.dashboard-section').removeClass('active'); + $el_window.find(`.dashboard-section[data-section="${tabId}"]`).addClass('active'); + + document.querySelector('.dashboard-content').setAttribute('class', 'dashboard-content'); + document.querySelector('.dashboard-content').classList.add(tabId); + + // Call onActivate if exists + const tab = tabs.find(t => t.id === tabId); + if ( tab?.onActivate ) { + tab.onActivate($el_window); + } + } + } + } + + // Handle browser back/forward navigation + // This handler is called for both hashchange (manual hash changes) and popstate (back/forward) + const handleRouteChange = () => { + const route = window.parseDashboardRoute(); + const tab = route.tab; + const filePath = route.path; + + // Switch to correct tab + const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`); + if ( tab === 'home' ) { + // Home tab + $el_window.find('.dashboard-sidebar-item').removeClass('active'); + $el_window.find('.dashboard-sidebar-item').first().addClass('active'); + $el_window.find('.dashboard-section').removeClass('active'); + $el_window.find('.dashboard-section').first().addClass('active'); + document.querySelector('.dashboard-content').setAttribute('class', 'dashboard-content'); + } else if ( $targetTab.length > 0 ) { + $el_window.find('.dashboard-sidebar-item').removeClass('active'); + $targetTab.addClass('active'); + $el_window.find('.dashboard-section').removeClass('active'); + $el_window.find(`.dashboard-section[data-section="${tab}"]`).addClass('active'); + document.querySelector('.dashboard-content').setAttribute('class', 'dashboard-content'); + document.querySelector('.dashboard-content').classList.add(tab); + } + + // If files tab with path, navigate without adding to history + if ( tab === 'files' && filePath ) { + const filesTab = tabs.find(t => t.id === 'files'); + if ( filesTab?.renderDirectory ) { + filesTab.renderDirectory(filePath, { skipUrlUpdate: true, skipNavHistory: true }); + } + } + }; + + // Listen for both hashchange and popstate to handle all navigation scenarios + window.addEventListener('hashchange', handleRouteChange); + window.addEventListener('popstate', handleRouteChange); + // Sidebar item click handler $el_window.on('click', '.dashboard-sidebar-item', function () { const $this = $(this); const section = $this.attr('data-section'); - + // Update active sidebar item $el_window.find('.dashboard-sidebar-item').removeClass('active'); $this.addClass('active'); - + // Update active content section $el_window.find('.dashboard-section').removeClass('active'); $el_window.find(`.dashboard-section[data-section="${section}"]`).addClass('active'); @@ -162,6 +389,16 @@ async function UIDashboard (options) { tab.onActivate($el_window); } + document.querySelector('.dashboard-content').setAttribute('class', 'dashboard-content'); + document.querySelector('.dashboard-content').classList.add(section); + + // Update hash to reflect current tab + // Note: Files tab updates its own hash with full path via onActivate, so skip it here + if ( section !== 'files' ) { + const newHash = section === 'home' ? '' : section; + history.replaceState(null, '', newHash ? `#${newHash}` : window.location.pathname); + } + // Close sidebar on mobile after selection $el_window.find('.dashboard-sidebar').removeClass('open'); $el_window.find('.dashboard-sidebar-toggle').removeClass('open'); @@ -173,14 +410,24 @@ async function UIDashboard (options) { $el_window.find('.dashboard-sidebar').toggleClass('open'); }); + // Close sidebar when clicking outside + $el_window.on('mousedown touchstart', function (e) { + if ( !$(e.target).closest('.dashboard-sidebar').length + && !$(e.target).closest('.dashboard-sidebar-toggle').length + && $el_window.find('.dashboard-sidebar').hasClass('open') ) { + $el_window.find('.dashboard-sidebar').removeClass('open'); + $el_window.find('.dashboard-sidebar-toggle').removeClass('open'); + } + }); + // User options button click handler - $el_window.on('click', '.dashboard-user-btn', function (e) { + $el_window.on('click', '.dashboard-user-btn', function () { const $btn = $(this); const $chevron = $btn.find('.dashboard-user-chevron'); const pos = this.getBoundingClientRect(); - + // Don't open if already open - if ($('.context-menu[data-id="dashboard-user-menu"]').length > 0) { + if ( $('.context-menu[data-id="dashboard-user-menu"]').length > 0 ) { return; } @@ -190,10 +437,10 @@ async function UIDashboard (options) { let items = []; // Save Session (if temp user) - if (window.user.is_temp) { + if ( window.user.is_temp ) { items.push({ html: i18n('save_session'), - icon: '', + icon: '', onClick: async function () { UIWindowSaveAccount({ send_confirmation_code: false, @@ -212,7 +459,7 @@ async function UIDashboard (options) { } // Logged in users - if (window.logged_in_users.length > 0) { + if ( window.logged_in_users.length > 0 ) { let users_arr = window.logged_in_users; // bring logged in user's item to top @@ -226,7 +473,7 @@ async function UIDashboard (options) { html: l_user.username, icon: l_user.username === window.user.username ? '✓' : '', onClick: async function () { - if (l_user.username === window.user.username) { + if ( l_user.username === window.user.username ) { return; } window.update_auth_data(l_user.auth_token, l_user); @@ -288,7 +535,7 @@ async function UIDashboard (options) { html: i18n('log_out'), onClick: async function () { // Check for open windows - if ($('.window-app').length > 0) { + if ( $('.window-app').length > 0 ) { const alert_resp = await UIAlert({ message: `

${i18n('confirm_open_apps_log_out')}

`, buttons: [ @@ -302,7 +549,7 @@ async function UIDashboard (options) { }, ], }); - if (alert_resp === 'close_and_log_out') { + if ( alert_resp === 'close_and_log_out' ) { window.logout(); } } else { @@ -315,15 +562,15 @@ async function UIDashboard (options) { UIContextMenu({ id: 'dashboard-user-menu', parent_element: $btn[0], - position: { + position: { top: pos.top - 8, - left: pos.left + left: pos.left, }, items: menuItems, onClose: () => { // Rotate chevron back to point downwards $chevron.removeClass('open'); - } + }, }); }); diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index aa08e10c4..3184becce 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -107,7 +107,7 @@ const sendSelectionToAIApp = async ($elements) => { }, '*'); }; -function UIItem (options) { +async function UIItem (options) { const matching_appendto_count = $(options.appendTo).length; if ( matching_appendto_count > 1 ) { $(options.appendTo).each(function () { @@ -138,7 +138,16 @@ function UIItem (options) { options.shortcut_to_path = options.shortcut_to_path ?? ''; options.immutable = (options.immutable === false || options.immutable === 0 || options.immutable === undefined ? 0 : 1); options.sort_container_after_append = (options.sort_container_after_append !== undefined ? options.sort_container_after_append : false); - const is_shared_with_me = (options.path && options.path !== `/${window.user.username}` && !options.path.startsWith(`/${window.user.username}/`)); + const is_shared_with_me = (options.path !== `/${window.user.username}` && !options.path.startsWith(`/${window.user.username}/`)); + let worker_url; + let is_worker; + if ( ! options.is_dir ) { + const stats = await puter.fs.stat({ path: options.path, returnWorkers: true }); + is_worker = stats.workers !== undefined && stats.workers.length > 0; + if ( is_worker ) { + worker_url = stats.workers[0].address; + } + } let website_url = window.determine_website_url(options.path); @@ -165,6 +174,8 @@ function UIItem (options) { data-website_url = "${website_url ? html_encode(website_url) : ''}" data-immutable="${options.immutable}" data-is_shortcut = "${options.is_shortcut}" + data-is_worker = "${is_worker !== undefined ? 1 : 0}" + data-worker_url = "${is_worker !== undefined ? worker_url : 0}" data-shortcut_to = "${html_encode(options.shortcut_to)}" data-shortcut_to_path = "${html_encode(options.shortcut_to_path)}" data-sortable = "${options.sortable ?? 'true'}" @@ -240,7 +251,12 @@ function UIItem (options) { data-item-id="${item_id}" title="${i18n('item_shortcut')}" >`; - + // worker badge + h += ``; h += '
'; // divider @@ -1880,6 +1896,50 @@ $(document).on('click', '.website-badge-popover-link', function (e) { $(e.target).closest('.popover').remove(); }); +$(document).on('long-hover', '.item-is-worker', function (e) { + const worker_url = e.target.parentNode.parentNode.getAttribute('data-worker_url'); + var box = e.target.getBoundingClientRect(); + + var body = document.body; + var docEl = document.documentElement; + + var scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; + var scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft; + + var clientTop = docEl.clientTop || body.clientTop || 0; + var clientLeft = docEl.clientLeft || body.clientLeft || 0; + + var top = box.top + scrollTop - clientTop; + var left = box.left + scrollLeft - clientLeft; + + if ( worker_url ) { + let h = '
'; + h += `
${i18n('worker')}
`; + h += ` + ${worker_url.replace('https://', '')} +
`; + h += '
'; + + // close other worker popovers + $('.worker-badge-popover-content').closest('.popover').remove(); + + // show a UIPopover with the worker URL + UIPopover({ + target: e.target, + content: h, + snapToElement: e.target, + parent_element: e.target, + top: top - 30, + left: left + 20, + }); + } +}); + +$(document).on('click', '.worker-badge-popover-link', function (e) { + // remove the parent popover + $(e.target).closest('.popover').remove(); +}); + // removes item(s) $.fn.removeItems = async function (options) { options = options || {}; diff --git a/src/gui/src/UI/UIWindowItemProperties.js b/src/gui/src/UI/UIWindowItemProperties.js index fc92218f3..e468f8bdc 100644 --- a/src/gui/src/UI/UIWindowItemProperties.js +++ b/src/gui/src/UI/UIWindowItemProperties.js @@ -42,6 +42,7 @@ async function UIWindowItemProperties (item_name, item_path, item_uid, left, top h += `${i18n('modified')}`; h += `${i18n('created')}`; h += `${i18n('versions')}`; + h += `${i18n('worker')}`; h += `${i18n('associated_websites')}`; h += ''; h += `${i18n('access_granted_to')}`; @@ -109,7 +110,7 @@ async function UIWindowItemProperties (item_name, item_path, item_uid, left, top returnVersions: true, returnSize: true, consistency: 'eventual', - success: function (fsentry) { + success: async function (fsentry) { // hide versions tab if item is a directory if ( fsentry.is_dir ) { $(el_window).find('[data-tab="versions"]').hide(); @@ -134,7 +135,6 @@ async function UIWindowItemProperties (item_name, item_path, item_uid, left, top // Ignored } } - // shortcut to if ( fsentry.shortcut_to && fsentry.shortcut_to_path ) { $(el_window).find('.item-prop-val-shortcut-to').text(fsentry.shortcut_to_path); @@ -168,7 +168,14 @@ async function UIWindowItemProperties (item_name, item_path, item_uid, left, top else { $(el_window).find('.item-props-version-list').append('-'); } - + // worker + if ( fsentry.path.endsWith('.js') ) { + const has_worker = fsentry.workers.length > 0; + if ( has_worker ) { + const worker_url = fsentry.workers[0].address; + $(el_window).find('.item-prop-val-worker').html(`${html_encode(worker_url)}`); + } + } $(el_window).find('.disassociate-website-link').on('click', function (e) { puter.hosting.update($(e.target).attr('data-subdomain'), null).then(() => { @@ -178,7 +185,7 @@ async function UIWindowItemProperties (item_name, item_path, item_uid, left, top // remove the website badge from all instances of the dir $(`.item[data-uid="${item_uid}"]`).find('.item-has-website-badge').fadeOut(200); } - }); + }); }); }, }); diff --git a/src/gui/src/UI/UIWindowPublishWebsite.js b/src/gui/src/UI/UIWindowPublishWebsite.js index 6a3a6e0a8..28a7ac0be 100644 --- a/src/gui/src/UI/UIWindowPublishWebsite.js +++ b/src/gui/src/UI/UIWindowPublishWebsite.js @@ -234,7 +234,6 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d }); window.update_sites_cache(); - } else if ( publishingType === 'custom' ) { // Handle custom domain publishing with Entri let customDomain = $(el_window).find('.publish-website-custom-domain').val(); @@ -279,7 +278,6 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d }); window.update_sites_cache(); - $(el_window).close(); } diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css new file mode 100644 index 000000000..3046db08f --- /dev/null +++ b/src/gui/src/css/dashboard.css @@ -0,0 +1,2891 @@ +/* ====================================== + Dashboard + ====================================== */ + +.dashboard * { + box-sizing: border-box; +} + +:root { + --select-hue: 213.05; + --select-saturation: 74.22%; + --select-lightness: 55.88%; + --select-color: hsl(var(--select-hue), var(--select-saturation), var(--select-lightness)); + + --dashboard-text: #444; + --dashboard-border: #e0e0e0; + --dashboard-background: #ffffff; + --dashboard-hover: #e8e8e8; + --dashboard-icon: #999; + --dashboard-sidebar-background: #f5f5f5; + + --dashboard-text-primary: #1e293b; + --dashboard-text-secondary: #666; + --dashboard-text-tertiary: #888; + --dashboard-text-heading: #333; + --dashboard-text-card-title: #1a1a1a; + --dashboard-text-username: #414b62; + --dashboard-text-hint: #64748b; + --dashboard-text-muted: #94a3b8; + + --dashboard-card-background: #ffffff; + --dashboard-card-gradient-start: #f8fafc; + --dashboard-card-gradient-end: #e2e8f0; + --dashboard-avatar-background: #ddd; + --dashboard-input-background: rgb(245, 247, 249); + + --dashboard-link: #5271ff; + --dashboard-link-hover: #3d5bd9; + + --dashboard-warning-icon: #ffbb00; + --dashboard-warning-background: #fef3c7; + --dashboard-warning-border: #f59e0b; + --dashboard-warning-text: #92400e; + --dashboard-warning-hover-bg: #fde68a; + --dashboard-warning-hover-border: #d97706; + --dashboard-warning-hover-text: #78350f; + + --dashboard-success-background: #e6ffed; + --dashboard-success-border: #08bf4e; + --dashboard-success-text: #03933a; + + --dashboard-danger-text: #dc2626; + --dashboard-danger-background: #fef2f2; + --dashboard-danger-border: #fecaca; + --dashboard-error-text: #dc2626; + + --dashboard-icon-blue-start: #3b82f6; + --dashboard-icon-blue-end: #2563eb; + --dashboard-icon-blue-shadow: rgba(59, 130, 246, 0.3); + --dashboard-icon-green-start: #10b981; + --dashboard-icon-green-end: #059669; + --dashboard-icon-green-shadow: rgba(16, 185, 129, 0.3); + + --dashboard-fancy-header-start: rgba(200, 220, 255, 0.5); + --dashboard-fancy-header-end: rgba(180, 210, 255, 0.3); + + --dashboard-shadow-subtle: rgba(0, 0, 0, 0.04); + --dashboard-shadow-light: rgba(0, 0, 0, 0.06); + --dashboard-shadow-medium: rgba(0, 0, 0, 0.1); + --dashboard-shadow-overlay: rgba(0, 0, 0, 0.5); + + --dashboard-gradient-indigo: rgba(99, 102, 241, 0.08); + --dashboard-gradient-purple: rgba(168, 85, 247, 0.06); + --dashboard-gradient-pink: rgba(236, 72, 153, 0.04); + --dashboard-gradient-green: rgba(34, 197, 94, 0.05); + --dashboard-gradient-blue: rgba(59, 130, 246, 0.05); + + --dashboard-usage-bar-background: #e5e7eb; + --dashboard-usage-bar-start: #f59e0b; + --dashboard-usage-bar-end: #f97316; + + --dashboard-legacy-bar-start: #dbe3ef; + --dashboard-legacy-bar-mid: #c2ccdc; +} + +body { + min-height: 100vh; +} + +@media (prefers-color-scheme: dark) { + :root { + --primary-color: var(--dashboard-border); + --primary-color-icon: invert(1); + --primary-color-sidebar-item: #e8e8e8; + + --dashboard-text: #d4d4d4; + --dashboard-border: #3d3d3d; + --dashboard-background: #1e1e1e; + --dashboard-hover: #2a2a2a; + --dashboard-icon: #888; + --dashboard-sidebar-background: #252525; + + --dashboard-text-primary: #e2e8f0; + --dashboard-text-secondary: #a1a1aa; + --dashboard-text-tertiary: #71717a; + --dashboard-text-heading: #f4f4f5; + --dashboard-text-card-title: #fafafa; + --dashboard-text-username: #c4cad6; + --dashboard-text-hint: #94a3b8; + --dashboard-text-muted: #64748b; + + --dashboard-card-background: #262626; + --dashboard-card-gradient-start: #2a2a2a; + --dashboard-card-gradient-end: #1f1f1f; + --dashboard-avatar-background: #3f3f3f; + --dashboard-input-background: #2d2d2d; + + --dashboard-link: #6b8cff; + --dashboard-link-hover: #8aa4ff; + + --dashboard-warning-icon: #fbbf24; + --dashboard-warning-background: #422006; + --dashboard-warning-border: #b45309; + --dashboard-warning-text: #fcd34d; + --dashboard-warning-hover-bg: #4a2608; + --dashboard-warning-hover-border: #d97706; + --dashboard-warning-hover-text: #fde68a; + + --dashboard-success-background: #052e16; + --dashboard-success-border: #16a34a; + --dashboard-success-text: #4ade80; + + --dashboard-danger-text: #f87171; + --dashboard-danger-background: #2a1515; + --dashboard-danger-border: #7f1d1d; + --dashboard-error-text: #f87171; + + --dashboard-icon-blue-start: #60a5fa; + --dashboard-icon-blue-end: #3b82f6; + --dashboard-icon-blue-shadow: rgba(96, 165, 250, 0.25); + --dashboard-icon-green-start: #34d399; + --dashboard-icon-green-end: #10b981; + --dashboard-icon-green-shadow: rgba(52, 211, 153, 0.25); + + --dashboard-fancy-header-start: rgba(60, 80, 120, 0.4); + --dashboard-fancy-header-end: rgba(50, 70, 100, 0.3); + + --dashboard-shadow-subtle: rgba(0, 0, 0, 0.2); + --dashboard-shadow-light: rgba(0, 0, 0, 0.3); + --dashboard-shadow-medium: rgba(0, 0, 0, 0.4); + --dashboard-shadow-overlay: rgba(0, 0, 0, 0.7); + + --dashboard-gradient-indigo: rgba(99, 102, 241, 0.15); + --dashboard-gradient-purple: rgba(168, 85, 247, 0.12); + --dashboard-gradient-pink: rgba(236, 72, 153, 0.08); + --dashboard-gradient-green: rgba(34, 197, 94, 0.1); + --dashboard-gradient-blue: rgba(59, 130, 246, 0.1); + + --dashboard-usage-bar-background: #3f3f46; + --dashboard-usage-bar-start: #fbbf24; + --dashboard-usage-bar-end: #fb923c; + + --dashboard-legacy-bar-start: #3f3f46; + --dashboard-legacy-bar-mid: #52525b; + } +} + +.dashboard { + display: flex; + height: 100%; + background: var(--dashboard-background); +} + +.dashboard-sidebar { + width: 200px; + min-width: 200px; + background: var(--dashboard-sidebar-background); + border-right: 1px solid var(--dashboard-border); + padding: 16px 12px; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.dashboard-sidebar-nav { + flex: 1; +} + +.dashboard-sidebar-item { + display: flex; + position: relative; + align-items: center; + gap: 10px; + padding: 10px 12px; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + color: var(--dashboard-text); + transition: background-color 0.15s; +} + +.dashboard-sidebar-item svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.dashboard-sidebar-item:hover { + background: var(--dashboard-hover); +} + +.dashboard-sidebar-item.active { + background: var(--dashboard-border); + font-weight: 500; +} + +.dashboard-sidebar-item.beta:after { + content: "Beta"; + font-size: 12px; + color: var(--dashboard-background); + background: var(--dashboard-text-muted); + padding: 1px 4px; + border-radius: 4px; + position: absolute; + right: 4px; +} + +/* User options button at bottom of sidebar */ +.dashboard-user-options { + border-top: 1px solid var(--dashboard-border); + padding-top: 12px; + margin-top: 8px; +} + +.dashboard-user-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.15s; +} + +.dashboard-user-btn:hover { + background: var(--dashboard-hover); +} + +.dashboard-user-btn.has-open-contextmenu { + background: var(--dashboard-hover); +} + +.dashboard-user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: var(--dashboard-avatar-background); + flex-shrink: 0; +} + +.dashboard-user-name { + flex: 1; + font-size: 14px; + color: var(--dashboard-text-heading); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-user-chevron { + width: 16px; + height: 16px; + color: var(--dashboard-text-tertiary); + flex-shrink: 0; + transition: transform 0.1s ease; +} + +.dashboard-user-chevron.open { + transform: rotate(180deg); +} + +.dashboard-content { + flex: 1; + padding: 24px 32px; + overflow-y: auto; +} + +.dashboard-section { + display: none; +} + +.dashboard-section.active { + display: block; +} + +.dashboard-section h2 { + margin: 0 0 16px 0; + font-size: 20px; + font-weight: 600; + color: var(--dashboard-text-heading); +} + +.dashboard-section p { + font-size: 14px; +} + +.dashboard-section-apps { + max-width: 600px; + margin: 0 auto; +} + +.dashboard-section-usage { + max-width: 600px; + margin: 0 auto; +} + +.dashboard #storage-used-percent { + color: var(--dashboard-text-tertiary); +} + +/* Dashboard Apps */ +.dashboard-apps-container { + margin-top: 8px; +} + +.dashboard-apps-heading { + font-size: 14px; + font-weight: 600; + color: var(--dashboard-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 16px 0; +} + +.dashboard-apps-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 20px; + margin-bottom: 8px; +} + +.dashboard-app-card { + width: 100%; + transition: background-color 0.15s, transform 0.1s; + transition: transform 0.15s ease; +} + +.dashboard-app-card .start-app { + cursor: pointer; + width: 90px; +} +.dashboard-app-card:hover { + background: none !important; +} + +.dashboard-app-card:hover .start-app, .dashboard-app-card .start-app:hover { + background: none !important; +} + +.dashboard-app-card:active { + transform: scale(0.97); +} + +.dashboard-app-card .start-app { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.dashboard-app-icon { + width: 48px; + height: 48px; + margin-bottom: 8px; + filter: drop-shadow(0px 1px 2px var(--dashboard-shadow-medium)); +} + +.dashboard-app-card .start-app{ + transition: transform 0.15s ease; +} +.dashboard-app-card .start-app:hover { + transform: scale(1.05); +} + +.dashboard-app-title { + font-size: 13px; + color: var(--dashboard-text-heading); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.dashboard-no-apps { + color: var(--dashboard-text-tertiary); + font-size: 14px; + padding: 24px 0; +} + +/* Dashboard files */ + +.dashboard-content.files { + padding: 0 0 0 10px; + overflow: hidden; +} + +.dashboard-tab-content.files-tab { + display: flex; + justify-content: flex-start; + align-items: flex-start; + max-width: unset; + padding: 0; + margin: 0; +} + +.dashboard-tab-content.files-tab h2 { + margin: 0 0 6px 0; + font-size: 26px; + font-weight: 700; + color: var(--dashboard-text-primary); + letter-spacing: -0.02em; +} + +.dashboard-section-files .directories { + position: sticky; + top: 0; + width: 160px; + padding: 16px 0; +} + +.dashboard-section-files .directories ul { + list-style: none; + padding: 0; + margin: 0; +} + +.dashboard-section-files .directories li { + display: flex; + align-items: center; + gap: 10px; + margin-top: 3px; + margin-right: 10px; + padding: 3px 0px 3px 5px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: var(--dashboard-text); + border: 2px dashed transparent; + transition: background-color 0.15s; +} + +.dashboard-section-files .directories li:hover { + background: var(--dashboard-hover); +} + +.dashboard-section-files .directories li.context-menu-active { + background: var(--dashboard-hover); +} + +.dashboard-section-files .directories li.active { + color: var(--select-color); + font-weight: 500; +} + +.dashboard-section-files .directories li img { + width: 28px; + height: auto; +} + +.dashboard-section-files .directories li[data-folder="Trash"] { + position: fixed; + bottom: 0; + margin-bottom: 20px; + width: 150px; + height: 38px; +} + +.dashboard-section-files .directory-contents { + position: relative; + width: calc(100% - 160px); + min-height: 100vh; + border-left: 1px solid var(--dashboard-border); +} + +.dashboard-section-files .directory-contents .header { + position: sticky; + top: 0; + height: 94px; + padding-top: 12px; + background: var(--dashboard-background); +} + +.dashboard-section-files .header .path { + font-size: 14px; + height: 47px; + display: flex; + align-items: center; + justify-content: flex-start; + padding: 10px 12px; + background: linear-gradient(0deg, var(--dashboard-sidebar-background), var(--dashboard-background)); + border-bottom: 1px solid var(--dashboard-border); +} + +.dashboard .path-nav-buttons { + padding: 4px 10px 4px 0px; + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-right: 5px; + border-right: 1px dotted var(--dashboard-border); +} + +.dashboard .path-btn { + opacity: 0.4; + width: 28px; + cursor: pointer; + border-radius: 5px; + padding: 4px; + background-color: transparent; + transition: background-color 0.3s ease-in-out; +} + +.dashboard .path-btn:hover { + filter: invert(1) hue-rotate(180deg); + background-color: var(--select-color); + opacity: 1; +} + +@media (prefers-color-scheme: dark) { + .path-btn { + filter: invert(1) hue-rotate(180deg); + } + .path-btn:hover { + filter: invert(0) hue-rotate(0deg); + background-color: var(--select-color); + opacity: 1; + } +} + +.dashboard-section-files .header .path-breadcrumbs { + display: flex; + align-items: center; + margin-left: 10px; +} + +.dashboard-section-files .header .path-breadcrumbs:empty + .path-actions { + display: none; +} + +.dashboard-section-files .header .path-actions { + display: flex; + gap: 10px; + margin-left: auto; +} + +.dashboard-section-files .header .path-action-btn { + background-color: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--dashboard-icon); + display: flex; + align-items: center; + justify-content: center; +} + +.dashboard-section-files .header .path-action-btn:hover { + color: var(--dashboard-background); + background-color: var(--select-color); +} + +.dashboard-section-files .header .path-btn-disabled { + opacity: 0.1; + pointer-events: none; +} + +.dashboard-section-files .header .path-action-btn svg { + width: 24px; + height: 24px; +} + +.dashboard-section-files .header .path .dirname { + height: auto; + font-weight: 400; + -webkit-font-smoothing: subpixel-antialiased; + color: var(--dashboard-text-secondary); + cursor: pointer; + background-color: transparent; + padding: 3px 6px; + font-size: 13px; + border: 1px solid transparent; + border-radius: 6px; +} + +.dashboard-section-files .header .path .dirname:hover { + color: var(--dashboard-background); + background-color: var(--select-color); + border: 1px solid var(--select-color); +} + +.dashboard-section-files .header .path .dirname.drop-target { + color: var(--dashboard-text); + background-color: rgba(59, 130, 246, 0.15); + border: 1px dashed var(--select-color); +} + +.dashboard-section-files .header .path .dirname.context-menu-active { + color: var(--dashboard-background); + background-color: var(--select-color); + border: 1px solid var(--select-color); +} + +.dashboard-section-files .header .columns { + display: grid; + height: 32px; + padding: 0 10px; + grid-template-columns: 24px auto 4px 100px 4px 120px 4px 20px; + align-items: center; + margin: 1px 2px; + color: var(--dashboard-text-secondary); + border-bottom: 1px solid var(--dashboard-border); + font-size: 12px; +} + +.dashboard-section-files .files { + width: 100%; + height: calc(100vh - 124px); + display: flex; + flex-direction: column; + padding-bottom: 30px; + overflow-y: auto; +} + +.dashboard-section-files .row { + display: grid; + width: unset; + height: 32px; + padding: 0 10px; + grid-template-columns: 24px auto 4px 100px 4px 120px 4px 20px; + align-items: center; + font-size: 13px; + color: var(--dashboard-text); + margin: 1px 2px; + pointer-events: auto; + float: unset; +} + +.dashboard-section-files .row.folder { + border: 1px solid transparent; +} + +.dashboard-section-files .row:hover { + background: var(--dashboard-hover); + border-radius: 3px; +} + +.dashboard-section-files .row.selected { + color: var(--primary-color-sidebar-item); + background-color: var(--select-color); + border-radius: 3px; +} + +@keyframes item-added-highlight { + from { background-color: var(--select-color); } + to { background-color: transparent; } +} + +.dashboard-section-files .row.item-newly-added { + animation: item-added-highlight 2s ease-out; +} + +.dashboard-section-files .row img { + width: 18px; + height: 18px; +} + +.dashboard-section-files .row .item-icon, +.dashboard-section-files .header .columns .item-icon { + padding: inherit; + height: 100%; + width: 100%; + filter: none; + margin: 0; +} + +.dashboard-section-files .row .item-name-wrapper { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} + +.dashboard-section-files .row .item-name, +.dashboard-section-files .header .columns .item-name { + /* text-overflow: ellipsis; */ + white-space: nowrap; + overflow: hidden; + max-width: unset; + color: currentColor; + text-shadow: none; + padding: 0 8px; + margin: 0; + font-size: inherit; + font-weight: 500; + word-break: inherit; + line-height: 32px; +} + +.dashboard-section-files .row textarea { + align-items: center; + width: 100%; + height: 20px; + margin: 0; + padding: 3px 8px 0 8px; + text-align: left; + font-weight: inherit; + display: none; + white-space: nowrap; +} + +.dashboard-section-files .row .item-size { + white-space: nowrap; + overflow: hidden; + line-height: 32px; + text-align: left; +} + +.dashboard-section-files .row .item-modified { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + line-height: 32px; + text-align: left; +} + +.dashboard-section-files .row .item-more { + color: var(--dashboard-border); + cursor: pointer; +} + +.dashboard-section-files .row .item-more svg { + pointer-events: none; +} + +.dashboard-section-files .row:hover .item-more { + color: var(--dashboard-text); +} + +.dashboard-section-files.ui-draggable-dragging { + background-color: transparent !important; + opacity: 1 !important; +} + +/* --- List view drag ghost --- */ + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row, +.dashboard-section-files.item-selected-clone .files-list-view .row { + background-color: var(--select-color) !important; + color: var(--dashboard-background) !important; + border-radius: 3px; + cursor: move; + width: auto !important; + display: grid !important; + grid-template-columns: 24px auto !important; + align-items: center; + height: 32px; +} + +.dashboard-section-files.item-selected-clone .files-list-view .row { + opacity: 0.6; + pointer-events: none; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-name-wrapper, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-name-wrapper { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-icon, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-icon { + width: 24px; + height: 24px; + padding: 0; + background: white; + border-radius: 2px; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-icon img, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-icon img { + width: 18px; + height: 18px; + object-fit: cover; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-name, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-name { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: unset; + color: currentColor; + text-shadow: none; + padding: 0 8px; + margin: 0; + font-size: 12px; + font-weight: 500; + word-break: inherit; + line-height: 32px; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-metadata, +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .col-spacer, +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-more, +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-name-editor, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-metadata, +.dashboard-section-files.item-selected-clone .files-list-view .row .col-spacer, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-more, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-name-editor { + display: none !important; +} + +/* --- Grid view drag ghost --- */ + +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row, +.dashboard-section-files.item-selected-clone .files-grid-view .row { + background-color: var(--select-color) !important; + color: var(--dashboard-background) !important; + cursor: move; +} + +.dashboard-section-files.item-selected-clone .files-grid-view .row { + opacity: 0.6; + pointer-events: none; +} + +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-name-wrapper, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-name-wrapper { + /* display: none !important; */ +} + +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-metadata, +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .col-spacer, +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-more, +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-name-editor, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-metadata, +.dashboard-section-files.item-selected-clone .files-grid-view .row .col-spacer, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-more, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-name-editor { + display: none !important; +} + +.dashboard-section-files .row.folder.ui-droppable-hover, +.dashboard-section-files .row.folder.selected.ui-droppable-over { + color: var(--dashboard-text); + background-color: rgba(59, 130, 246, 0.1); + border: 2px dashed var(--select-color); + border-radius: 3px; +} + +/* Spring-loaded folder dwell animation */ +.dashboard-section-files .row.folder.dwell-opening, +.dashboard-section-files .directories li.dwell-opening { + background: linear-gradient(90deg, rgba(59, 130, 246, 0.15) 100%, transparent 100%); + background-size: 0% 100%; + background-repeat: no-repeat; + border: 2px dashed var(--select-color); + border-radius: 3px; + animation: dwell-fill 700ms linear forwards; +} + +@keyframes dwell-fill { + from { background-size: 0% 100%; } + to { background-size: 100% 100%; } +} + +.dashboard-section-files .draggable-count-badge { + position: fixed; + background: var(--select-color); + color: var(--dashboard-background); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + pointer-events: none; + z-index: 10001; +} + +/* Drag cancel zone — shown after spring-loaded folder navigation */ +.drag-cancel-zone { + position: absolute; + bottom: 32px; + right: 32px; + background: #ef4444; + color: white; + padding: 16px 32px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 10001; + user-select: none; + cursor: default; + transition: background 0.15s, transform 0.15s; +} + +.drag-cancel-zone.drag-cancel-hover { + background: #dc2626; + transform: scale(1.05); +} + +.dashboard-section-files .directories li.ui-droppable-hover.active { + background-color: rgba(59, 130, 246, 0.1); + border: 2px dashed var(--select-color); + border-radius: 4px; +} + +/* Native file drop visual feedback for Dashboard */ +.dashboard-section-files .files.native-drop-active { + background-color: rgba(0, 120, 212, 0.08); + outline: 2px dashed #0078d4; + outline-offset: -2px; + border-radius: 4px; +} + +.dashboard-section-files .directories li.native-drop-target { + background-color: rgba(0, 120, 212, 0.15); + border-radius: 4px; +} + +.dashboard-section-files .files .row.folder.native-drop-target { + background-color: rgba(0, 120, 212, 0.15); +} + +/* Dark mode support for native file drop */ +.window[data-color-scheme="dark"] .dashboard-section-files .files.native-drop-active { + background-color: rgba(100, 180, 255, 0.12); + outline-color: #4da3ff; +} + +.window[data-color-scheme="dark"] .dashboard-section-files .directories li.native-drop-target, +.window[data-color-scheme="dark"] .dashboard-section-files .files .row.folder.native-drop-target { + background-color: rgba(100, 180, 255, 0.2); +} + +.dashboard-section-files .files-footer { + position: fixed; + bottom: 0; + right: 0; + left: 371px; + background: linear-gradient(180deg, var(--dashboard-sidebar-background), var(--dashboard-background)); + border-top: 1px solid var(--dashboard-border); + height: 30px; + font-size: 13px; + line-height: 28px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #666; + z-index: 10; +} + +.dashboard-section-files .files-footer-separator, +.dashboard-section-files .files-footer-selected-items { + display: none; +} + +.dashboard-section-files .files-footer-separator { + color: #CCC; +} + +/* Floating Selection Actions Bar */ +.dashboard-section-files .files-selection-actions { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--dashboard-card-background); + border: 1px solid var(--dashboard-border); + border-radius: 12px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 4px; + box-shadow: 0 4px 20px var(--dashboard-shadow-medium), + 0 2px 8px var(--dashboard-shadow-light); + z-index: 15; + opacity: 0; + visibility: hidden; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.25s ease, + visibility 0.25s ease; +} + +.dashboard-section-files .files-selection-actions.visible { + transform: translateX(-50%) translateY(0); + opacity: 1; + visibility: visible; + z-index: 99999999999999999; +} + +.dashboard-section-files .files-selection-actions.rubberband-active { + pointer-events: none; +} + +.dashboard-section-files .selection-action-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dashboard-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.dashboard-section-files .selection-action-btn:hover { + background: var(--dashboard-hover); +} + +.dashboard-section-files .selection-action-btn:active { + transform: scale(0.97); +} + +.dashboard-section-files .selection-action-btn svg { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.dashboard-section-files .selection-action-btn.restore-btn { + color: #43a047; +} + +.dashboard-section-files .selection-action-btn.restore-btn:hover { + background: rgba(67, 160, 71, 0.1); +} + +.dashboard-section-files .selection-action-btn.delete-btn { + color: #e53935; +} + +.dashboard-section-files .selection-action-btn.delete-btn:hover { + background: rgba(229, 57, 53, 0.1); +} + +/* Select mode button - hidden on desktop by default */ +.dashboard-section-files .header .path-action-btn.select-mode-btn { + display: none; +} + +/* Done button in floating action bar - hidden by default, shown in select mode on mobile */ +.dashboard-section-files .files-selection-actions .done-btn { + display: none; +} + +/* Checkbox in item rows - hidden by default */ +.dashboard-section-files .files-tab .files .row .item-checkbox { + display: none; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-section-files .files-tab .files .row .item-checkbox .checkbox-icon { + width: 20px; + height: 20px; + border: 2px solid var(--dashboard-border); + border-radius: 4px; + background: var(--dashboard-card-background); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.dashboard-section-files .files-tab .files .row.selected .item-checkbox .checkbox-icon { + background: var(--primary-color, #3b82f6); + border-color: var(--primary-color, #3b82f6); +} + +.dashboard-section-files .files-tab .files .row.selected .item-checkbox .checkbox-icon::after { + content: ''; + width: 6px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-bottom: 2px; +} + +.dashboard-section-files .files-tab .files.files-list-view .row { + display: grid; + grid-template-columns: 24px auto 4px 100px 4px 120px 4px 20px; + height: 32px; + padding: 0 10px; + align-items: center; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-icon { + position: relative; + width: 24px; + height: 24px; + padding: 0; + background: var(--dashboard-background); + border-radius: 2px; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-icon img { + width: 18px; + height: 18px; + object-fit: cover; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-size, +.dashboard-section-files .files-tab .files.files-list-view .row .item-modified, +.dashboard-section-files .files-tab .files.files-list-view .row .item-more { + display: block; + padding: 0 10px; +} + +.dashboard-section-files .files-tab .files.files-grid-view { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 10px; + padding: 16px; + align-content: start; + margin-bottom: 30px; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-badges { + width: 36px; + height: 36px; + top: 0; + left: 0; + right: 0; + bottom: 0; + justify-content: flex-end; + align-items: flex-start; +} + +.dashboard-section-files .files-tab .files.files-list-view .row img.item-badge { + width: 12px !important; + height: 12px !important; + margin: 0 -2px; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-badges { + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + justify-content: flex-end; + align-items: flex-start; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row img.item-badge { + width: 20px !important; + height: 20px !important; + margin: 5px; + border-radius: 50%; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + padding: 12px; + height: auto; + gap: 8px; + border: 1px solid var(--dashboard-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); +} + +.dashboard-section-files .files-tab .files.files-grid-view .row:hover { + background: var(--dashboard-sidebar-background); + border-color: var(--dashboard-border); +} + +.dashboard-section-files .files-tab .files.files-grid-view .row.selected { + background-color: var(--select-color); + color: var(--dashboard-background); + border-color: var(--select-color); +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon { + width: 150px; + height: 150px; + display: flex; + align-items: center; + justify-content: center; + /* background: #fafafa; */ + border-radius: 8px; + overflow: hidden; + background: white; + border-radius: 2px; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon img { + width: 100%; + height: 100%; + object-fit: contain; + max-width: fit-content; + max-height: fit-content; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon svg { + width: 64px; + height: 64px; + opacity: 0.5; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-name { + text-align: center; + width: 100%; + padding: 0; + font-size: 13px; + line-height: 1.4; + max-height: 2.8em; + overflow: hidden; + word-break: break-word; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-size, +.dashboard-section-files .files-tab .files.files-grid-view .row .item-modified, +.dashboard-section-files .files-tab .files.files-grid-view .row .item-more, +.dashboard-section-files .files-tab .files.files-grid-view .row .col-spacer { + display: none; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-name-wrapper { + width: 100%; + display: block; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row:hover .item-more { + position: absolute; + top: 1px; + right: 1px; + color: #666; + background: var(--dashboard-sidebar-background); + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 7px; +} + +/* Hide .item-more on desktop (non-touch devices) - use right-click context menu instead */ +.dashboard-section-files .files-tab:not(.touch-device) .files.files-list-view .row .item-more, +.dashboard-section-files .files-tab:not(.touch-device) .files.files-grid-view .row .item-more, +.dashboard-section-files .files-tab:not(.touch-device) .files.files-grid-view .row:hover .item-more, +.dashboard-section-files .files-tab:not(.touch-device) .header .columns .item-more { + display: none !important; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-name-editor { + text-align: center; +} + +.dashboard-section-files .files-tab.files-grid-mode .header .columns { + display: none; +} + +/* Sortable column headers */ +.dashboard-section-files .header .columns .sortable { + cursor: pointer; + user-select: none; + position: relative; + display: flex; + align-items: center; + gap: 4px; + padding: 0 10px; + justify-content: space-between; +} + +.dashboard-section-files .header .columns .sortable:hover { + color: var(--dashboard-text-heading); +} + +.dashboard-section-files .header .columns .sortable::after { + content: ''; + display: inline-block; + width: 0; + height: 0; + margin-left: 4px; + opacity: 0.3; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid currentColor; +} + +.dashboard-section-files .header .columns .sortable.sort-asc::after { + opacity: 1; + border-bottom: 5px solid currentColor; + border-top: none; +} + +.dashboard-section-files .header .columns .sortable.sort-desc::after { + opacity: 1; + border-top: 5px solid currentColor; + border-bottom: none; +} + +/* Column resize handles */ +.dashboard-section-files .header .columns .col-resize-handle { + width: 4px; + height: 100%; + cursor: col-resize; + background: transparent; + position: relative; + background: var(--dashboard-sidebar-background); +} + +.dashboard-section-files .header .columns .col-resize-handle:hover { + background: var(--dashboard-border); +} + +.dashboard-section-files .header .columns .col-resize-handle:active { + background: var(--select-color); +} + +.dashboard-section-files .more-btn { + background: none; + border: none; + padding: 6px; + cursor: pointer; + color: var(--text-muted); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.15s ease; + position: relative; +} + +.dashboard-section-files .more-menu { + position: absolute; + min-width: 180px; + background: var(--dashboard-background); + border: 1px solid var(--dashboard-border); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + z-index: 1000; + padding: 4px; +} + +.dashboard-section-files .more-menu .menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + background: none; + border: none; + border-radius: 6px; + font-size: 0.85rem; + color: var(--dashboard-text); + cursor: pointer; + transition: background 0.15s ease; + text-align: left; +} + +.dashboard-section-files .more-menu .menu-item:hover { + background: var(--dashboard-hover); +} + +.dashboard-section-files .more-menu .menu-item svg { + width: 16px; + height: 16px; + color: var(--dashboard-border); + flex-shrink: 0; +} + +.dashboard-section-files .more-menu .menu-item.has-submenu { + position: relative; +} + +.dashboard-section-files .more-menu .menu-item.has-submenu svg:last-child { + margin-left: auto; + width: 0.85rem; + height: 0.85rem; +} + +.dashboard-section-files .more-menu .menu-item.danger { + color: #ea4335; +} + +.dashboard-section-files .more-menu .menu-item.danger svg { + color: #ea4335; +} + +.dashboard-section-files .more-menu .menu-item.danger:hover { + background: rgba(234, 67, 53, 0.1); +} + +.dashboard-section-files .more-menu .menu-divider { + height: 1px; + background: var(--dashboard-border); + margin: 4px 0; +} + +/* Mobile sidebar toggle */ +.dashboard-sidebar-toggle { + display: none; + position: fixed; + top: 12px; + left: 12px; + z-index: 100; + width: 40px; + height: 40px; + background: var(--dashboard-background); + border: 1px solid var(--dashboard-border); + border-radius: 6px; + cursor: pointer; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; +} + +.dashboard-sidebar-toggle span { + display: block; + width: 18px; + height: 2px; + background: var(--dashboard-text); + border-radius: 1px; + transition: transform 0.2s, opacity 0.2s; +} + +.dashboard-sidebar-toggle.open span:nth-child(1) { + transform: rotate(45deg) translate(4px, 4px); +} + +.dashboard-sidebar-toggle.open span:nth-child(2) { + opacity: 0; +} + +.dashboard-sidebar-toggle.open span:nth-child(3) { + transform: rotate(-45deg) translate(4px, -4px); +} + +.dashboard-sidebar-separator { + height: 1px; + background: var(--dashboard-border); + margin: 8px 0; +} + +/* Responsive: tablet and below */ +@media (max-width: 768px) { + .dashboard-sidebar-nav { + padding-top: 45px; + } + .dashboard-sidebar-toggle { + display: flex; + } + + .dashboard-sidebar { + position: fixed; + left: 0; + top: 0; + height: 100%; + z-index: 99; + transform: translateX(-110%); + transition: transform 0.2s ease; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + } + + .dashboard-sidebar.open { + transform: translateX(0); + } + + .dashboard-content { + padding: 64px 16px 24px; + } + + .dashboard-apps-grid { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 4px; + } + + .dashboard-app-card { + padding: 10px 6px; + } + + .dashboard-app-icon { + width: 40px; + height: 40px; + } +} + +/* Desktop: Make metadata wrapper transparent */ +.dashboard-section-files .files-tab .files.files-list-view .row .item-metadata { + display: contents; +} + +/* Image preview popover */ +.image-preview-popover { + position: fixed; + z-index: 9999; + background: var(--dashboard-background); + border: 1px solid var(--dashboard-border); + border-radius: 8px; + padding: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.image-preview-popover img { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + border-radius: 4px; +} + +.image-preview-name { + font-size: 14px; + color: var(--dashboard-text); + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Mobile phone optimizations */ +@media (max-width: 480px) { + .dashboard-content.files { + padding: 0; + } + + /* Hide directories sidebar */ + .dashboard-section-files .directories { + display: none; + } + + /* Full width for directory contents */ + .dashboard-section-files .directory-contents { + width: 100%; + border-left: none; + } + + .dashboard-section-files .directory-contents .header { + margin-bottom: 12px; + } + + /* Hide column headers */ + .dashboard-section-files .header .columns { + display: none; + } + + /* Two-row header layout */ + .dashboard-section-files .header .path { + flex-wrap: wrap; + height: auto; + padding: 8px 12px; + } + + .dashboard-section-files .header .path-breadcrumbs { + order: 1; + width: 100%; + margin: 0 0 8px 0; + padding-bottom: 8px; + margin-left: 50px; + flex-wrap: nowrap; + white-space: nowrap; + overflow-x: auto; + border-bottom: 1px solid var(--dashboard-border); + } + + .dashboard-section-files .header .path-nav-buttons { + order: 2; + border-right: none; + margin-right: 0; + } + + .dashboard-section-files .header .path-actions { + order: 3; + margin-left: auto; + } + + /* Two-row item layout */ + .dashboard-section-files .files-tab .files.files-list-view .row { + display: grid; + grid-template-columns: 48px 1fr !important; + grid-template-rows: auto auto; + height: auto; + padding: 6px 10px; + gap: 2px 8px; + } + + /* Thumbnail spans both rows */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-icon { + grid-row: 1 / 3; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-icon img { + width: 40px; + height: 40px; + } + + /* File name on first row */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-name-wrapper { + grid-column: 2; + grid-row: 1; + padding-right: 40px; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-name { + padding: 0; + line-height: 24px; + } + + /* Metadata wrapper for second row */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-metadata { + grid-column: 2; + grid-row: 2; + display: flex; + align-items: center; + font-size: 11px; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-metadata .col-spacer { + display: none; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-modified, + .dashboard-section-files .files-tab .files.files-list-view .row .item-size { + font-size: 11px; + padding: 0; + line-height: 24px; + color: var(--dashboard-text-muted); + } + + .dashboard-section-files .files-tab .files.files-list-view .row:hover .item-modified, + .dashboard-section-files .files-tab .files.files-list-view .row:hover .item-size, + .dashboard-section-files .files-tab .files.files-list-view .row.selected .item-modified, + .dashboard-section-files .files-tab .files.files-list-view .row.selected .item-size { + /* color: var(--primary-color-sidebar-item); */ + } + + /* Bullet separator between size and modified */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-size:not(:empty)::after { + content: '•'; + margin: 0 6px; + } + + /* Hide outer spacers */ + .dashboard-section-files .files-tab .files.files-list-view .row > .col-spacer { + display: none; + } + + /* Hide more button (use long-press for context menu on touch) */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-more { + position: absolute; + right: 10px; + } + + /* Adjust footer position - full width since sidebar is hidden */ + .dashboard-section-files .files-footer { + left: 0; + padding: 0; + } + + /* Mobile: Floating selection actions - icon-only, full width */ + .dashboard-section-files .files-selection-actions { + left: 0; + right: 0; + bottom: 38px; + transform: translateX(0) translateY(100px); + border-radius: 0; + justify-content: center; + padding: 10px 8px; + } + + .dashboard-section-files .files-selection-actions.visible { + transform: translateX(0) translateY(0); + } + + .dashboard-section-files .selection-action-btn span { + display: none; + } + + .dashboard-section-files .selection-action-btn { + padding: 9px 8px; + border-radius: 50%; + } + + /* Mobile: Show select mode button */ + .dashboard-section-files .header .path-action-btn.select-mode-btn { + display: flex; + } + + .dashboard-section-files .header .path-action-btn.select-mode-btn.active { + background: var(--primary-color, #3b82f6); + color: white; + border-radius: 6px; + } + + /* Mobile: Show checkboxes in select mode */ + .dashboard-section-files .files-tab.select-mode-active .files .row .item-checkbox { + display: flex; + grid-row: 1 / 3; + } + + /* Mobile: Adjust grid for checkbox in list view */ + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row { + grid-template-columns: 32px 48px 1fr !important; + } + + /* Adjust grid-column for content when checkbox is visible */ + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row .item-icon { + grid-column: 2; + } + + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row .item-name-wrapper { + grid-column: 3; + } + + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row .item-metadata { + grid-column: 3; + } + + /* Mobile: Show Done button in floating action bar during select mode */ + .dashboard-section-files .files-tab.select-mode-active .files-selection-actions .done-btn { + display: flex; + background: var(--primary-color, #3b82f6); + color: white; + padding: 5px; + margin-left: 8px; + } + + /* Mobile: Grid view checkbox positioning */ + .dashboard-section-files .files-tab.select-mode-active .files.files-grid-view .row { + position: relative; + } + + .dashboard-section-files .files-tab.select-mode-active .files.files-grid-view .row .item-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + } +} + +/* Full HD */ +@media (min-width: 1920px) { + .dashboard-section-home .bento-container { + max-width: 1000px; + } + .dashboard-section-usage{ + max-width: 800px; + } +} +/* 4K UHD */ +@media (min-width: 2560px) { + .dashboard-section-home .bento-container { + max-width: 1200px; + } + .dashboard-section-usage{ + max-width: 900px; + } +} + +/* ============================================== */ +/* Bento Box Home Dashboard */ +/* ============================================== */ + +.dashboard .bento-container { + display: grid; + grid-template-columns: 280px 1fr; + gap: 20px; + max-width: 800px; + margin: 0 auto; + padding: 8px 0; + align-items: stretch; +} + +.dashboard .bento-card { + background: var(--dashboard-background); + border-radius: 20px; + overflow: hidden; + box-shadow: + 0 1px 3px var(--dashboard-shadow-subtle), + 0 4px 12px var(--dashboard-shadow-subtle); + border: 1px solid var(--dashboard-shadow-light); +} + +/* Welcome Card */ +.dashboard .bento-welcome { + position: relative; + background: linear-gradient(135deg, var(--dashboard-card-gradient-start) 0%, var(--dashboard-card-gradient-end) 100%); + color: var(--dashboard-text-primary); + min-height: 280px; +} + +.dashboard .bento-welcome-inner { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 24px; + box-sizing: border-box; +} + +.dashboard .bento-welcome-pattern { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 20% 30%, var(--dashboard-gradient-indigo) 0%, transparent 50%), + radial-gradient(circle at 80% 70%, var(--dashboard-gradient-purple) 0%, transparent 40%); + pointer-events: none; +} + +.dashboard .bento-welcome-content { + position: relative; + z-index: 1; +} + +.dashboard .bento-welcome-avatar { + width: 72px; + height: 72px; + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: var(--dashboard-avatar-background); + border: 3px solid var(--dashboard-background); + margin-bottom: 16px; + box-shadow: 0 4px 16px var(--dashboard-shadow-medium); +} + +.dashboard .bento-greeting { + font-size: 14px; + color: var(--dashboard-text-hint); + font-weight: 400; + letter-spacing: 0.02em; + display: block; + margin-bottom: 4px; +} + +.dashboard .bento-username { + font-size: 25px; + font-weight: 700; + margin: 0 0 8px 0; + line-height: 1.1; + letter-spacing: -0.02em; + color: var(--dashboard-text-username); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard .bento-tagline { + font-size: 13px; + margin: 0; + font-weight: 400; +} + +.dashboard .bento-save-account-warning { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 12px; + padding: 8px 14px; + background-color: var(--dashboard-warning-background); + border: 1px solid var(--dashboard-warning-border); + border-radius: 8px; + color: var(--dashboard-warning-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: all 0.15s ease; +} + +.dashboard .bento-save-account-warning:hover { + background-color: var(--dashboard-warning-hover-bg); + border-color: var(--dashboard-warning-hover-border); + color: var(--dashboard-warning-hover-text); +} + +.dashboard .bento-save-account-warning svg { + flex-shrink: 0; + color: var(--dashboard-warning-icon); +} + +.dashboard .bento-save-account-warning:hover svg { + color: var(--dashboard-warning-hover-border); +} + +/* Recent Apps Card - Rectangle */ +.dashboard .bento-recent { + min-height: 310px; + display: flex; + flex-direction: column; + background: + radial-gradient(circle at 90% 10%, var(--dashboard-gradient-indigo) 0%, transparent 40%), + radial-gradient(circle at 10% 90%, var(--dashboard-gradient-pink) 0%, transparent 35%), + linear-gradient(135deg, var(--dashboard-card-gradient-start) 0%, var(--dashboard-card-gradient-end) 100%); +} + +.dashboard .bento-card-header { + padding: 20px 24px 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.dashboard .bento-card-header h2 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--dashboard-text-card-title); + letter-spacing: -0.01em; +} + +/* Fancy header with icon */ +.dashboard .bento-card-fancy-header { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 24px; + background: linear-gradient(135deg, var(--dashboard-fancy-header-start) 0%, var(--dashboard-fancy-header-end) 100%); + border-bottom: 1px solid var(--dashboard-shadow-light); +} + +.dashboard .bento-card-fancy-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard .bento-card-fancy-icon svg { + width: 28px; + height: 28px; +} + +.dashboard .bento-card-fancy-icon-apps { + background: linear-gradient(135deg, var(--dashboard-icon-blue-start) 0%, var(--dashboard-icon-blue-end) 100%); + color: white; + box-shadow: 0 4px 12px var(--dashboard-icon-blue-shadow); +} + +.dashboard .bento-card-fancy-icon-usage { + background: linear-gradient(135deg, var(--dashboard-icon-green-start) 0%, var(--dashboard-icon-green-end) 100%); + color: white; + box-shadow: 0 4px 12px var(--dashboard-icon-green-shadow); +} + +.dashboard .bento-card-fancy-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.dashboard .bento-card-fancy-text h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--dashboard-text-primary); + letter-spacing: -0.02em; +} + +.dashboard .bento-card-fancy-subtitle { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: var(--dashboard-text-hint); +} + +.dashboard .bento-card-fancy-subtitle svg { + width: 14px; + height: 14px; +} + +.dashboard .bento-view-more { + font-size: 13px; + color: var(--dashboard-link); + text-decoration: none; + font-weight: 500; + transition: color 0.15s ease; +} + +.dashboard .bento-view-more:hover { + color: var(--dashboard-link-hover); + text-decoration: underline; +} + +.dashboard .bento-recent-apps-container { + flex: 1; + padding: 16px 24px 24px; +} + +.dashboard .bento-recent-apps-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 20px; +} + +/* Hide apps beyond 6 on smaller screens */ +.dashboard .bento-recent-app:nth-child(n+7) { + display: none; +} + +/* Show all 8 apps on 4K UHD screens */ +@media (min-width: 2560px) { + .dashboard .bento-recent-apps-grid { + grid-template-columns: repeat(2, 1fr); + } + + .dashboard .bento-recent-app:nth-child(n+7) { + display: flex; + } +} + +.dashboard .bento-recent-app { + display: flex; + flex-direction: row; + align-items: center; + padding: 8px 0; + cursor: pointer; + transition: transform 0.15s ease; + gap: 12px; + width: 200px; +} + +.dashboard .bento-recent-app:hover { + transform: scale(1.02); +} + +.dashboard .bento-recent-app:active { + transform: scale(0.98); +} + +.dashboard .bento-recent-app-icon { + width: 36px; + height: 36px; + border-radius: 8px; + flex-shrink: 0; +} + +.dashboard .bento-recent-app-title { + font-size: 13px; + font-weight: 500; + color: var(--dashboard-text); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +/* Empty state for recent apps */ +.dashboard .bento-recent-apps-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + height: 180px; + text-align: center; + color: var(--dashboard-icon); +} + +.dashboard .bento-recent-apps-empty svg { + width: 48px; + height: 48px; + stroke: var(--dashboard-border); + margin-bottom: 16px; +} + +.dashboard .bento-recent-apps-empty p { + margin: 0 0 4px 0; + font-size: 14px; + font-weight: 500; + color: var(--dashboard-text-secondary); +} + +.dashboard .bento-recent-apps-empty span { + font-size: 13px; + color: var(--dashboard-text-tertiary); +} + +/* Usage bento card */ +.dashboard .bento-usage { + grid-column: 1 / -1; + min-height: auto; + background: + radial-gradient(circle at 5% 50%, var(--dashboard-gradient-green) 0%, transparent 40%), + radial-gradient(circle at 95% 50%, var(--dashboard-gradient-blue) 0%, transparent 40%), + linear-gradient(135deg, var(--dashboard-card-gradient-start) 0%, var(--dashboard-card-gradient-end) 100%); +} + +.dashboard .bento-usage-container { + padding: 16px 24px 24px; +} + +.dashboard .bento-usage-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 24px; +} + +.dashboard .bento-usage-section { + display: flex; + flex-direction: column; +} + +/* Your Plan section styles */ +.dashboard .bento-plan-section { + justify-content: flex-start; +} + +.dashboard .bento-plan-info { + flex-grow: 1; +} + +.dashboard .bento-plan-badge { + font-weight: 400; +} + +.dashboard .bento-plan-badge.active { + color: var(--dashboard-success-text); +} + +.dashboard .bento-plan-upgrade { + font-size: 14px; + font-weight: 500; + color: var(--dashboard-link); + text-decoration: none; + transition: color 0.2s; +} + +.dashboard .bento-plan-upgrade:hover { + color: var(--dashboard-link-hover); + text-decoration: underline; +} + +.dashboard .bento-usage-section-header { + display: flex; + flex-direction: row; + align-items: baseline; + margin-bottom: 8px; +} + +.dashboard .bento-usage-section-header h3 { + margin: 0; + font-size: 14px; + font-weight: 500; + color: var(--dashboard-text-primary); + flex-grow: 1; +} + +.dashboard .bento-usage-section-values { + font-size: 13px; + color: var(--dashboard-text-primary); + opacity: 0.85; +} + +/* New card-based usage styles */ +.dashboard .bento-usage-card { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dashboard .bento-usage-card-header { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + cursor: pointer; + transition: opacity 0.2s; +} + +.dashboard .bento-usage-card-header:hover { + opacity: 0.7; +} + +.dashboard .bento-usage-card-header h3 { + margin: 0; + font-size: 16px; + font-weight: 500; + color: var(--dashboard-text-primary); +} + +.dashboard .bento-usage-card-arrow { + font-size: 18px; + font-weight: 300; + color: var(--dashboard-text-hint); + line-height: 1; +} + +.dashboard .bento-usage-card-bar-wrapper { + width: 100%; + height: 14px; + background-color: var(--dashboard-usage-bar-background); + border-radius: 7px; + overflow: hidden; +} + +.dashboard .bento-usage-card-bar { + height: 100%; + background: linear-gradient(90deg, var(--dashboard-usage-bar-start), var(--dashboard-usage-bar-end)); + border-radius: 7px; + width: 0; + transition: width 0.4s ease; +} + +.dashboard .bento-usage-card-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dashboard .bento-usage-card-used { + font-size: 20px; + font-weight: 600; + color: var(--dashboard-text-primary); +} + +.dashboard .bento-usage-card-details { + font-size: 14px; + color: var(--dashboard-text-hint); +} + +/* Legacy bar styles (kept for compatibility) */ +.dashboard .bento-usage-bar-wrapper { + width: 100%; + height: 20px; + border: 1px solid var(--dashboard-border); + border-radius: 3px; + background-color: var(--dashboard-card-background); + position: relative; + display: flex; + align-items: center; +} + +.dashboard .bento-usage-bar { + height: 20px; + background: linear-gradient(var(--dashboard-legacy-bar-start), var(--dashboard-legacy-bar-mid), var(--dashboard-legacy-bar-start)); + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; + width: 0; + transition: width 0.3s ease; +} + +/* Responsive bento layout */ +@media (max-width: 768px) { + .dashboard .bento-container { + grid-template-columns: 1fr; + gap: 16px; + padding: 0; + } + + .dashboard .bento-welcome { + aspect-ratio: auto; + min-height: 180px; + } + + .dashboard .bento-welcome-inner { + padding: 20px; + } + + .dashboard .bento-username { + font-size: 24px; + } + + .dashboard .bento-welcome-avatar { + width: 56px; + height: 56px; + margin-bottom: 12px; + border-width: 2px; + } + + .dashboard .bento-recent-apps-grid { + grid-template-columns: 1fr; + gap: 8px; + } + + .dashboard .bento-recent-app { + padding: 6px 0; + } + + .dashboard .bento-recent-app-icon { + width: 32px; + height: 32px; + } + + .dashboard .bento-recent-app-title { + font-size: 12px; + } + + .dashboard .bento-usage-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + .dashboard .bento-usage-container { + padding: 16px 20px 20px; + } + + .dashboard .bento-usage-card-header h3 { + font-size: 15px; + } + + .dashboard .bento-usage-card-arrow { + font-size: 17px; + } + + .dashboard .bento-usage-card-used { + font-size: 18px; + } + + .dashboard .bento-usage-card-details { + font-size: 13px; + } + + .dashboard .bento-card-fancy-header { + padding: 16px 20px; + gap: 12px; + } + + .dashboard .bento-card-fancy-icon { + width: 40px; + height: 40px; + border-radius: 10px; + } + + .dashboard .bento-card-fancy-icon svg { + width: 22px; + height: 22px; + } + + .dashboard .bento-card-fancy-text h2 { + font-size: 17px; + } + + .dashboard .bento-card-fancy-subtitle { + font-size: 12px; + } +} + +/* ============================================== */ +/* Dashboard Account Tab */ +/* ============================================== */ + +.dashboard-tab-content { + max-width: 700px; + margin: 0 auto; + padding: 8px 0; +} + +.dashboard-section-header { + margin-bottom: 28px; +} + +.dashboard-section-header h2 { + margin: 0 0 6px 0; + font-size: 26px; + font-weight: 700; + color: var(--dashboard-text-primary); + letter-spacing: -0.02em; +} + +.dashboard-section-header p { + margin: 0; + font-size: 15px; + color: var(--dashboard-text-hint); +} + +.dashboard-card { + background: var(--dashboard-card-background); + border-radius: 16px; + border: 1px solid var(--dashboard-shadow-light); + box-shadow: + 0 1px 3px var(--dashboard-shadow-subtle), + 0 4px 12px rgba(0, 0, 0, 0.03); +} + +/* Profile card */ +.dashboard-profile-card { + padding: 32px; + margin-bottom: 24px; + background: + radial-gradient(circle at 100% 0%, var(--dashboard-gradient-purple) 0%, transparent 50%), + radial-gradient(circle at 0% 100%, rgba(168, 85, 247, 0.04) 0%, transparent 40%), + var(--dashboard-card-background); +} + +.dashboard-profile-picture-section { + display: flex; + align-items: center; + gap: 24px; +} + +.dashboard-profile-avatar { + width: 96px; + height: 96px; + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: var(--dashboard-card-gradient-end); + flex-shrink: 0; + position: relative; + cursor: pointer; + transition: transform 0.2s ease; + box-shadow: 0 4px 16px var(--dashboard-shadow-medium); +} + +.dashboard-profile-avatar:hover { + transform: scale(1.05); +} + +.dashboard-profile-avatar-overlay { + position: absolute; + inset: 0; + background: var(--dashboard-shadow-overlay); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; +} + +.dashboard-profile-avatar:hover .dashboard-profile-avatar-overlay { + opacity: 1; +} + +.dashboard-profile-avatar-overlay svg { + width: 28px; + height: 28px; + color: white; +} + +.dashboard-profile-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dashboard-profile-info h3 { + margin: 0; + font-size: 22px; + font-weight: 700; + color: var(--dashboard-text-primary); +} + +.dashboard-profile-info p { + margin: 0; + font-size: 14px; + color: var(--dashboard-text-hint); +} + +.dashboard-profile-hint { + font-size: 12px; + color: var(--dashboard-text-muted); + margin-top: 4px; +} + +/* Settings grid */ +.dashboard-settings-grid { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 32px; +} + +.dashboard-settings-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + gap: 16px; +} + +.dashboard-settings-card-content { + display: flex; + align-items: center; + gap: 16px; + flex: 1; + min-width: 0; +} + +.dashboard-settings-card-icon { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, var(--dashboard-card-gradient-start) 0%, var(--dashboard-card-gradient-end) 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-settings-card-icon svg { + width: 22px; + height: 22px; + color: var(--dashboard-text-secondary); +} + +.dashboard-settings-card-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.dashboard-settings-card-info strong { + font-size: 14px; + font-weight: 600; + color: var(--dashboard-text-primary); +} + +.dashboard-settings-card-info span { + font-size: 14px; + color: var(--dashboard-text-hint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-settings-card .button { + flex-shrink: 0; + color: var(--dashboard-text); + background: linear-gradient(var(--dashboard-sidebar-background), var(--dashboard-border)); +} + +.dashboard-settings-card-success { + border-color: var(--dashboard-success-border); + background: var(--dashboard-success-background); +} + +.dashboard-settings-card-success .dashboard-settings-card-info span { + color: var(--dashboard-success-text); +} + +.dashboard-settings-card-warning { + border-color: var(--dashboard-warning-border); + background: var(--dashboard-warning-background); +} + +.dashboard-settings-card-warning .dashboard-settings-card-info span { + color: var(--dashboard-warning-text); +} + +/* Danger zone */ +.dashboard-danger-zone { + padding-top: 24px; +} + +.dashboard-danger-zone h3 { + margin: 0 0 16px 0; + font-size: 14px; + font-weight: 600; + color: var(--dashboard-danger-text); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.dashboard-danger-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + gap: 24px; + border-color: var(--dashboard-danger-border); + background: linear-gradient(135deg, var(--dashboard-danger-background) 0%, var(--dashboard-card-background) 100%); +} + +.dashboard-danger-card-content { + flex: 1; + min-width: 0; +} + +.dashboard-danger-card-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dashboard-danger-card-info strong { + font-size: 15px; + font-weight: 600; + color: var(--dashboard-danger-text); +} + +.dashboard-danger-card-info span { + font-size: 13px; + color: var(--dashboard-text-hint); + line-height: 1.5; +} + +/* Responsive styles for Account tab */ +@media (max-width: 768px) { + .dashboard-tab-content { + padding: 0 16px; + } + + .dashboard-section-header h2 { + font-size: 22px; + } + + .dashboard-profile-card { + padding: 24px; + } + + .dashboard-profile-picture-section { + flex-direction: column; + text-align: center; + } + + .dashboard-profile-info { + align-items: center; + } + + .dashboard-settings-card { + flex-direction: column; + align-items: stretch; + gap: 16px; + padding: 16px 20px; + } + + .dashboard-settings-card .button { + width: 100%; + } + + .dashboard-danger-card { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .dashboard-danger-card .button { + width: 100%; + } +} + +/* ====================================== + Mobile Context Menu Modal + ====================================== */ + +/* Backdrop - full screen overlay */ +.context-menu-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--dashboard-shadow-overlay, rgba(0, 0, 0, 0.5)); + z-index: 1000; + opacity: 0; + z-index: 9999999; + transition: opacity 200ms ease-in-out; +} + +.context-menu-modal-backdrop.show { + opacity: 1; +} + +/* Modal dialog - positioned over item */ +.context-menu-modal-dialog { + position: fixed; + background: var(--dashboard-card-background, #ffffff); + border-radius: 0.75rem; + box-shadow: 0 0.5rem 2rem var(--dashboard-shadow-medium, rgba(0, 0, 0, 0.1)); + overflow: hidden; + transform: scale(0.9); + opacity: 0; + transition: transform 200ms ease-in-out, opacity 200ms ease-in-out; + max-height: calc(100vh - 40px); + display: flex; + flex-direction: column; +} + +.context-menu-modal-backdrop.show .context-menu-modal-dialog { + transform: scale(1); + opacity: 1; +} + +/* Menu items container */ +.context-menu-modal-dialog .context-menu-items { + display: flex; + flex-direction: column; + overflow-y: auto; +} + +/* Individual menu item */ +.context-menu-modal-dialog .context-menu-item { + display: flex; + align-items: center; + padding: 0.5rem; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + transition: background-color 150ms ease-in-out; + font-family: inherit; +} + +.context-menu-modal-dialog .context-menu-item:last-child { + border-bottom: none; +} + +.context-menu-modal-dialog .context-menu-item:hover { + background-color: var(--dashboard-hover, #e8e8e8); +} + +.context-menu-modal-dialog .context-menu-item:active { + background-color: var(--dashboard-hover, #e8e8e8); +} + +/* Menu item icon */ +.context-menu-modal-dialog .context-menu-item-icon { + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.context-menu-modal-dialog .context-menu-item-icon img { + width: 100%; + height: 100%; + opacity: 0.7; +} + +.context-menu-modal-dialog .context-menu-item-icon svg { + width: 100%; + height: 100%; + opacity: 0.7; +} + +@media (prefers-color-scheme: dark) { + .context-menu-modal-dialog .context-menu-item-icon img { + filter: invert(1); + } +} + +.context-menu-modal-dialog .context-menu-item:hover .context-menu-item-icon img, +.context-menu-modal-dialog .context-menu-item:hover .context-menu-item-icon svg { + opacity: 1; +} + +/* Menu item label */ +.context-menu-modal-dialog .context-menu-item-label { + font-size: 0.9rem; + font-weight: 500; + color: var(--dashboard-text, #444); +} + +/* Separator */ +.context-menu-modal-dialog .context-menu-separator { + height: 1px; + background-color: var(--dashboard-border, #e0e0e0); + margin: 0.25rem 0; +} + +/* Delete item - special styling */ +.context-menu-modal-dialog .context-menu-item--delete .context-menu-item-label { + color: var(--dashboard-danger-text, #dc2626); +} + +.context-menu-modal-dialog .context-menu-item--delete .context-menu-item-icon img, +.context-menu-modal-dialog .context-menu-item--delete .context-menu-item-icon svg { + opacity: 0.8; +} + +/* Desktop - transparent backdrop */ +@media (min-width: 768px) { + .context-menu-modal-backdrop { + background-color: transparent; + } +} + +/* ====================================== + Files Loading Spinner + ====================================== */ + +.files-loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + /* background-color: var(--dashboard-background); */ + z-index: 2147483647; + display: flex; + justify-content: center; + align-items: center; + pointer-events: all; + opacity: 0; + transition: opacity 2s ease-in; +} + +.files-loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + background: var(--dashboard-background); + border-radius: 10px; + padding: 20px; + min-width: 120px; +} + +.files-loading-spinner { + width: 50px; + height: 50px; + border: 5px solid var(--dashboard-border); + border-top: 5px solid var(--select-color); + border-radius: 50%; + animation: files-loading-spin 1s linear infinite; + margin-bottom: 10px; +} + +.files-loading-text { + font-family: Arial, sans-serif; + font-size: 16px; + margin-top: 10px; + text-align: center; + width: 100%; + color: var(--dashboard-text); +} + +@keyframes files-loading-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + + +.context-menu .context-menu-item:not(.context-menu-divider) { + display: flex; + align-items: center; +} + +.submenu-arrow { + margin-left: auto; +} diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 37288da0b..fe4aa4d12 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -256,7 +256,7 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, sel background: #8fc2e7 !important; border: 1px solid #98adbd !important; text-shadow: none !important; - color: #f5f5f5 !important; + color: white !important; } .button-block { @@ -665,6 +665,12 @@ span.header-sort-icon img { filter: drop-shadow(0px 0px 1px rgba(0, 0, 0, 1)); } +.item-badge.item-is-worker { + border-radius: 50%; + background: white; + cursor: pointer; +} + .item-name, .item-name-editor, .item-name-shadow { font-size: 12px; color: white; @@ -682,7 +688,6 @@ span.header-sort-icon img { .item-name { transition: opacity 0.2s ease-in-out; - cursor: default; max-width: 110px; pointer-events: all; } @@ -1343,7 +1348,7 @@ span.header-sort-icon img { } .window-sidebar-item-dragging { - background-color: #f5f5f5 !important; + background-color: white !important; opacity: 0.8; cursor: grabbing !important; } @@ -1808,6 +1813,19 @@ span.header-sort-icon img { border: none; } +/* TabFiles rubber band selection area */ +.tabfiles-selection-area { + background-color: rgba(59, 130, 246, 0.15); + border: 1px solid var(--select-color); + position: absolute; + pointer-events: none; + z-index: 1000; +} + +.dashboard-section-files .files { + position: relative; +} + .container { user-select: none; } @@ -2247,7 +2265,7 @@ label { padding: 11px; background-color: white; border-radius: 3px; - border: 1px solid #e0e0e0; + border: 1px solid var(--dashboard-border); color: #65707b; font-size: 13px; } @@ -2801,16 +2819,16 @@ label { top: 0; background-color: hsla(0, 0%, 100%, 0.8); text-align: left; - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--dashboard-border); padding: 5px; } .task-manager-container thead th:not(:last-of-type) { - border-right: 1px solid #e0e0e0; + border-right: 1px solid var(--dashboard-border); } .task-manager-container tbody > tr > td { - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--dashboard-border); padding: 0 calc(2.5 * var(--scale)); vertical-align: middle; padding-left: 0; @@ -3908,6 +3926,33 @@ fieldset[name=number-code] { text-decoration: underline; } +.worker-badge-popover-title { + font-size: 14px; + margin: -10px; + margin-bottom: 5px; + padding: 8px 10px; + background: #e5e5e5; + color: #4b5f6f; +} + +.worker-badge-popover-content { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 270px; + padding: 10px; +} + +.worker-badge-popover-link, .worker-badge-popover-link:visited { + color: #0073ed; + text-decoration: none; + width: 179px; +} + +.worker-badge-popover-link:hover { + text-decoration: underline; +} + /*! * animate.css - https://animate.style/ * Version - 4.1.1 @@ -4077,7 +4122,7 @@ fieldset[name=number-code] { } .puter-auth-dialog-content { - border: 1px solid #e8e8e8; + border: 1px solid var(--dashboard-hover); border-radius: 8px; padding: 20px; background: white; @@ -4233,7 +4278,7 @@ fieldset[name=number-code] { background: #8fc2e7 !important; border: 1px solid #98adbd !important; text-shadow: none !important; - color: #f5f5f5 !important; + color: var(--dashboard-sidebar-background) !important; } .puter-auth-dialog .button-block { @@ -4401,7 +4446,7 @@ fieldset[name=number-code] { .settings-sidebar { width: 200px; background-color: #f9f9f9; - border-right: 1px solid #e0e0e0; + border-right: 1px solid #3d3d3d; padding: 20px; position: fixed; margin-top: 1px; @@ -4463,7 +4508,7 @@ fieldset[name=number-code] { .settings-content h1 { font-size: 24px; margin-bottom: 20px; - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid #3d3d3d; padding-bottom: 10px; padding-left: 5px; font-weight: 500; @@ -4592,6 +4637,15 @@ fieldset[name=number-code] { flex-direction: column; } +.dashboard-section-usage { + color: var(--dashboard-text); +} + +.dashboard-section-usage .driver-usage { + color: var(--dashboard-text); + background-color: transparent; +} + .driver-usage-container .driver-usage{ flex-grow: 1; } @@ -4832,6 +4886,11 @@ html.dark-mode .usage-table-show-less:hover { font-weight: 500; } +.dashboard-section-usage .driver-usage-details-content-table thead th { + border-color: var(--dashboard-border); + background: var(--dashboard-input-background); +} + .driver-usage-details-content-table thead th.sortable-th { cursor: pointer; user-select: none; @@ -4842,6 +4901,14 @@ html.dark-mode .usage-table-show-less:hover { background-color: #e5e5e5; } +.dashboard-section-usage .driver-usage-details-content-table thead th.sortable-th { + background-color: var(--dashboard-input-background); +} + +.dashboard-section-usage .driver-usage-details-content-table thead th.sortable-th:hover { + background-color: var(--dashboard-shadow-light); +} + .driver-usage-details-content-table thead th .sort-icon { margin-left: 4px; display: inline-flex; @@ -4859,7 +4926,7 @@ html.dark-mode .usage-table-show-less:hover { .driver-usage-details-content-table td { padding: 7px 5px; - border: 1px solid #e0e0e0; + border: 1px solid var(--dashboard-border); max-width: 0; overflow: hidden; white-space: nowrap; @@ -4953,9 +5020,9 @@ html.dark-mode .usage-table-show-less:hover { } .settings-card-danger { - border-color: #f0080866; - background: #ffecec; - color: rgb(215 2 2); + border-color: #fecaca;; + background: #fef2f2; + color: #dc2626; } .settings-card-success { @@ -4965,9 +5032,9 @@ html.dark-mode .usage-table-show-less:hover { } .settings-card-warning { - border-color: #f0a500; - background: #fff7e6; - color: #c98900; + border-color: #f59e0b; + background: #fef3c7; + color: #92400e; } .error-message { @@ -5026,7 +5093,7 @@ html.dark-mode .usage-table-show-less:hover { display: flex; flex-direction: column; padding: 10px; - border: 1px solid #e0e0e0; + border: 1px solid var(--dashboard-border); border-radius: 4px; gap: 4px; } @@ -5594,7 +5661,7 @@ html.dark-mode .usage-table-show-less:hover { top: 5px; right: 5px; border-radius: 5px; - background: linear-gradient(to bottom, #f8f8f8, #e0e0e0); + background: linear-gradient(to bottom, #f8f8f8, var(--dashboard-border)); cursor: pointer; } .btn-show-ai svg{ @@ -5644,1220 +5711,3 @@ html.dark-mode .usage-table-show-less:hover { width: 20px; height: 20px; } - -/* ====================================== - Dashboard - ====================================== */ - -.dashboard { - display: flex; - height: 100%; - background: #fff; -} - -.dashboard-sidebar { - width: 200px; - min-width: 200px; - background: #f5f5f5; - border-right: 1px solid #e0e0e0; - padding: 16px 12px; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -.dashboard-sidebar-nav { - flex: 1; -} - -.dashboard-sidebar-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - margin-bottom: 4px; - border-radius: 6px; - cursor: pointer; - font-size: 14px; - color: #444; - transition: background-color 0.15s; -} - -.dashboard-sidebar-item svg { - width: 18px; - height: 18px; - flex-shrink: 0; -} - -.dashboard-sidebar-item:hover { - background: #e8e8e8; -} - -.dashboard-sidebar-item.active { - background: #e0e0e0; - font-weight: 500; -} - -/* User options button at bottom of sidebar */ -.dashboard-user-options { - border-top: 1px solid #e0e0e0; - padding-top: 12px; - margin-top: 8px; -} - -.dashboard-user-btn { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 6px; - cursor: pointer; - transition: background-color 0.15s; -} - -.dashboard-user-btn:hover { - background: #e8e8e8; -} - -.dashboard-user-btn.has-open-contextmenu { - background: #e8e8e8; -} - -.dashboard-user-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - background-size: cover; - background-position: center; - background-color: #ddd; - flex-shrink: 0; -} - -.dashboard-user-name { - flex: 1; - font-size: 14px; - color: #333; - font-weight: 500; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dashboard-user-chevron { - width: 16px; - height: 16px; - color: #888; - flex-shrink: 0; - transition: transform 0.1s ease; -} - -.dashboard-user-chevron.open { - transform: rotate(180deg); -} - -.dashboard-content { - flex: 1; - padding: 24px 32px; - overflow-y: auto; -} - -.dashboard-section { - display: none; -} - -.dashboard-section.active { - display: block; -} - -.dashboard-section h2 { - margin: 0 0 16px 0; - font-size: 20px; - font-weight: 600; - color: #333; -} - -.dashboard-section p { - font-size: 14px; -} - -.dashboard-section-apps { - max-width: 600px; - margin: 0 auto; -} - -.dashboard-section-usage { - max-width: 600px; - margin: 0 auto; -} - -/* Dashboard Apps */ -.dashboard-apps-container { - margin-top: 8px; -} - -.dashboard-apps-heading { - font-size: 14px; - font-weight: 600; - color: #666; - text-transform: uppercase; - letter-spacing: 0.5px; - margin: 0 0 16px 0; -} - -.dashboard-apps-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); - gap: 20px; - margin-bottom: 8px; -} - -.dashboard-app-card { - width: 100%; - transition: background-color 0.15s, transform 0.1s; - transition: transform 0.15s ease; -} - -.dashboard-app-card .start-app { - cursor: pointer; - width: 90px; -} -.dashboard-app-card:hover { - background: none !important; -} - -.dashboard-app-card:hover .start-app, .dashboard-app-card .start-app:hover { - background: none !important; -} - -.dashboard-app-card:active { - transform: scale(0.97); -} - -.dashboard-app-card .start-app { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.dashboard-app-icon { - width: 48px; - height: 48px; - margin-bottom: 8px; - filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.1)); -} - -.dashboard-app-card .start-app{ - transition: transform 0.15s ease; -} -.dashboard-app-card .start-app:hover { - transform: scale(1.05); -} - -.dashboard-app-title { - font-size: 13px; - color: #333; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.dashboard-no-apps { - color: #888; - font-size: 14px; - padding: 24px 0; -} - -/* Mobile sidebar toggle */ -.dashboard-sidebar-toggle { - display: none; - position: fixed; - top: 12px; - left: 12px; - z-index: 100; - width: 40px; - height: 40px; - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 6px; - cursor: pointer; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; -} - -.dashboard-sidebar-toggle span { - display: block; - width: 18px; - height: 2px; - background: #444; - border-radius: 1px; - transition: transform 0.2s, opacity 0.2s; -} - -.dashboard-sidebar-toggle.open span:nth-child(1) { - transform: rotate(45deg) translate(4px, 4px); -} - -.dashboard-sidebar-toggle.open span:nth-child(2) { - opacity: 0; -} - -.dashboard-sidebar-toggle.open span:nth-child(3) { - transform: rotate(-45deg) translate(4px, -4px); -} - -.dashboard-sidebar-separator { - height: 1px; - background: #e0e0e0; - margin: 8px 0; -} - -/* Responsive: tablet and below */ -@media (max-width: 768px) { - .dashboard-sidebar-nav { - padding-top: 45px; - } - .dashboard-sidebar-toggle { - display: flex; - } - - .dashboard-sidebar { - position: fixed; - left: 0; - top: 0; - height: 100%; - z-index: 99; - transform: translateX(-100%); - transition: transform 0.2s ease; - box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); - } - - .dashboard-sidebar.open { - transform: translateX(0); - } - - .dashboard-content { - padding: 64px 16px 24px; - } - - .dashboard-apps-grid { - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); - gap: 4px; - } - - .dashboard-app-card { - padding: 10px 6px; - } - - .dashboard-app-icon { - width: 40px; - height: 40px; - } -} - -/* Full HD */ -@media (min-width: 1920px) { - .dashboard-section-home .bento-container { - max-width: 1000px; - } - .dashboard-section-usage{ - max-width: 800px; - } -} -/* 4K UHD */ -@media (min-width: 2560px) { - .dashboard-section-home .bento-container { - max-width: 1200px; - } - .dashboard-section-usage{ - max-width: 900px; - } -} - -/* ============================================== */ -/* Bento Box Home Dashboard */ -/* ============================================== */ - -.bento-container { - display: grid; - grid-template-columns: 280px 1fr; - gap: 20px; - max-width: 800px; - margin: 0 auto; - padding: 8px 0; - align-items: stretch; -} - -.bento-card { - background: #fff; - border-radius: 20px; - overflow: hidden; - box-shadow: - 0 1px 3px rgba(0, 0, 0, 0.04), - 0 4px 12px rgba(0, 0, 0, 0.03); - border: 1px solid rgba(0, 0, 0, 0.06); -} - -/* Welcome Card */ -.bento-welcome { - position: relative; - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); - color: #1e293b; - min-height: 280px; -} - -.bento-welcome-inner { - position: relative; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: 24px; - box-sizing: border-box; -} - -.bento-welcome-pattern { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-image: - radial-gradient(circle at 20% 30%, rgba(99, 102, 241, 0.08) 0%, transparent 50%), - radial-gradient(circle at 80% 70%, rgba(168, 85, 247, 0.06) 0%, transparent 40%); - pointer-events: none; -} - -.bento-welcome-content { - position: relative; - z-index: 1; -} - -.bento-welcome-avatar { - width: 72px; - height: 72px; - border-radius: 50%; - background-size: cover; - background-position: center; - background-color: #e2e8f0; - border: 3px solid #fff; - margin-bottom: 16px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -} - -.bento-greeting { - font-size: 14px; - color: #64748b; - font-weight: 400; - letter-spacing: 0.02em; - display: block; - margin-bottom: 4px; -} - -.bento-username { - font-size: 25px; - font-weight: 700; - margin: 0 0 8px 0; - line-height: 1.1; - letter-spacing: -0.02em; - color: #414b62; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.bento-tagline { - font-size: 13px; - margin: 0; - font-weight: 400; -} - -.bento-save-account-warning { - display: inline-flex; - align-items: center; - gap: 6px; - margin-top: 12px; - padding: 8px 14px; - background-color: #fef3c7; - border: 1px solid #f59e0b; - border-radius: 8px; - color: #92400e; - font-size: 13px; - font-weight: 500; - cursor: pointer; - text-decoration: none; - transition: all 0.15s ease; -} - -.bento-save-account-warning:hover { - background-color: #fde68a; - border-color: #d97706; - color: #78350f; -} - -.bento-save-account-warning svg { - flex-shrink: 0; - color: #f59e0b; -} - -.bento-save-account-warning:hover svg { - color: #d97706; -} - -/* Recent Apps Card - Rectangle */ -.bento-recent { - min-height: 310px; - display: flex; - flex-direction: column; - background: - radial-gradient(circle at 90% 10%, rgba(99, 102, 241, 0.06) 0%, transparent 40%), - radial-gradient(circle at 10% 90%, rgba(236, 72, 153, 0.04) 0%, transparent 35%), - linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); -} - -.bento-card-header { - padding: 20px 24px 0; - display: flex; - align-items: center; - justify-content: space-between; -} - -.bento-card-header h2 { - margin: 0; - font-size: 15px; - font-weight: 600; - color: #1a1a1a; - letter-spacing: -0.01em; -} - -/* Fancy header with icon */ -.bento-card-fancy-header { - display: flex; - align-items: center; - gap: 14px; - padding: 20px 24px; - background: linear-gradient(135deg, rgba(200, 220, 255, 0.5) 0%, rgba(180, 210, 255, 0.3) 100%); - border-bottom: 1px solid rgb(0 0 0 / 1%); -} - -.bento-card-fancy-icon { - width: 48px; - height: 48px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.bento-card-fancy-icon svg { - width: 28px; - height: 28px; -} - -.bento-card-fancy-icon-apps { - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); - color: white; - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); -} - -.bento-card-fancy-icon-usage { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); -} - -.bento-card-fancy-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.bento-card-fancy-text h2 { - margin: 0; - font-size: 20px; - font-weight: 600; - color: #1e293b; - letter-spacing: -0.02em; -} - -.bento-card-fancy-subtitle { - display: flex; - align-items: center; - gap: 5px; - font-size: 13px; - color: #64748b; -} - -.bento-card-fancy-subtitle svg { - width: 14px; - height: 14px; -} - -.bento-view-more { - font-size: 13px; - color: #5271ff; - text-decoration: none; - font-weight: 500; - transition: color 0.15s ease; -} - -.bento-view-more:hover { - color: #3d5bd9; - text-decoration: underline; -} - -.bento-recent-apps-container { - flex: 1; - padding: 16px 24px 24px; -} - -.bento-recent-apps-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px 20px; -} - -/* Hide apps beyond 6 on smaller screens */ -.bento-recent-app:nth-child(n+7) { - display: none; -} - -/* Show all 8 apps on 4K UHD screens */ -@media (min-width: 2560px) { - .bento-recent-apps-grid { - grid-template-columns: repeat(2, 1fr); - } - - .bento-recent-app:nth-child(n+7) { - display: flex; - } -} - -.bento-recent-app { - display: flex; - flex-direction: row; - align-items: center; - padding: 8px 0; - cursor: pointer; - transition: transform 0.15s ease; - gap: 12px; - width: 200px; -} - -.bento-recent-app:hover { - transform: scale(1.02); -} - -.bento-recent-app:active { - transform: scale(0.98); -} - -.bento-recent-app-icon { - width: 36px; - height: 36px; - border-radius: 8px; - flex-shrink: 0; -} - -.bento-recent-app-title { - font-size: 13px; - font-weight: 500; - color: #333; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - flex: 1; - min-width: 0; -} - -/* Empty state for recent apps */ -.bento-recent-apps-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - height: 180px; - text-align: center; - color: #888; -} - -.bento-recent-apps-empty svg { - width: 48px; - height: 48px; - stroke: #ccc; - margin-bottom: 16px; -} - -.bento-recent-apps-empty p { - margin: 0 0 4px 0; - font-size: 14px; - font-weight: 500; - color: #666; -} - -.bento-recent-apps-empty span { - font-size: 13px; - color: #999; -} - -/* Usage bento card */ -.bento-usage { - grid-column: 1 / -1; - min-height: auto; - background: - radial-gradient(circle at 5% 50%, rgba(34, 197, 94, 0.05) 0%, transparent 40%), - radial-gradient(circle at 95% 50%, rgba(59, 130, 246, 0.05) 0%, transparent 40%), - linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); -} - -.bento-usage-container { - padding: 16px 24px 24px; -} - -.bento-usage-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 24px; -} - -.bento-usage-section { - display: flex; - flex-direction: column; -} - -/* Your Plan section styles */ -.bento-plan-section { - justify-content: flex-start; -} - -.bento-plan-info { - flex-grow: 1; -} - -.bento-plan-badge { - font-weight: 400; -} - -.bento-plan-badge.active { - color: #16a34a; -} - -.bento-plan-upgrade { - font-size: 14px; - font-weight: 500; - color: #3b82f6; - text-decoration: none; - transition: color 0.2s; -} - -.bento-plan-upgrade:hover { - color: #2563eb; - text-decoration: underline; -} - -.bento-usage-section-header { - display: flex; - flex-direction: row; - align-items: baseline; - margin-bottom: 8px; -} - -.bento-usage-section-header h3 { - margin: 0; - font-size: 14px; - font-weight: 500; - color: #3c4963; - flex-grow: 1; -} - -.bento-usage-section-values { - font-size: 13px; - color: #3c4963; - opacity: 0.85; -} - -/* New card-based usage styles */ -.bento-usage-card { - display: flex; - flex-direction: column; - gap: 16px; -} - -.bento-usage-card-header { - display: flex; - align-items: center; - gap: 8px; - text-decoration: none; - cursor: pointer; - transition: opacity 0.2s; -} - -.bento-usage-card-header:hover { - opacity: 0.7; -} - -.bento-usage-card-header h3 { - margin: 0; - font-size: 16px; - font-weight: 500; - color: #1e293b; -} - -.bento-usage-card-arrow { - font-size: 18px; - font-weight: 300; - color: #64748b; - line-height: 1; -} - -.bento-usage-card-bar-wrapper { - width: 100%; - height: 14px; - background-color: #e5e7eb; - border-radius: 7px; - overflow: hidden; -} - -.bento-usage-card-bar { - height: 100%; - background: linear-gradient(90deg, #f59e0b, #f97316); - border-radius: 7px; - width: 0; - transition: width 0.4s ease; -} - -.bento-usage-card-info { - display: flex; - flex-direction: column; - gap: 4px; -} - -.bento-usage-card-used { - font-size: 20px; - font-weight: 600; - color: #1e293b; -} - -.bento-usage-card-details { - font-size: 14px; - color: #64748b; -} - -/* Legacy bar styles (kept for compatibility) */ -.bento-usage-bar-wrapper { - width: 100%; - height: 20px; - border: 1px solid #dddddd; - border-radius: 3px; - background-color: #fbfbfb; - position: relative; - display: flex; - align-items: center; -} - -.bento-usage-bar { - height: 20px; - background: linear-gradient(#dbe3ef, #c2ccdc, #dbe3ef); - border-top-left-radius: 3px; - border-bottom-left-radius: 3px; - width: 0; - transition: width 0.3s ease; -} - -/* Responsive bento layout */ -@media (max-width: 768px) { - .bento-container { - grid-template-columns: 1fr; - gap: 16px; - padding: 0; - } - - .bento-welcome { - aspect-ratio: auto; - min-height: 180px; - } - - .bento-welcome-inner { - padding: 20px; - } - - .bento-username { - font-size: 24px; - } - - .bento-welcome-avatar { - width: 56px; - height: 56px; - margin-bottom: 12px; - border-width: 2px; - } - - .bento-recent-apps-grid { - grid-template-columns: 1fr; - gap: 8px; - } - - .bento-recent-app { - padding: 6px 0; - } - - .bento-recent-app-icon { - width: 32px; - height: 32px; - } - - .bento-recent-app-title { - font-size: 12px; - } - - .bento-usage-grid { - grid-template-columns: 1fr; - gap: 16px; - } - - .bento-usage-container { - padding: 16px 20px 20px; - } - - .bento-usage-card-header h3 { - font-size: 15px; - } - - .bento-usage-card-arrow { - font-size: 17px; - } - - .bento-usage-card-used { - font-size: 18px; - } - - .bento-usage-card-details { - font-size: 13px; - } - - .bento-card-fancy-header { - padding: 16px 20px; - gap: 12px; - } - - .bento-card-fancy-icon { - width: 40px; - height: 40px; - border-radius: 10px; - } - - .bento-card-fancy-icon svg { - width: 22px; - height: 22px; - } - - .bento-card-fancy-text h2 { - font-size: 17px; - } - - .bento-card-fancy-subtitle { - font-size: 12px; - } -} - -/* ============================================== */ -/* Dashboard Account Tab */ -/* ============================================== */ - -.dashboard-tab-content { - max-width: 700px; - margin: 0 auto; - padding: 8px 0; -} - -.dashboard-section-header { - margin-bottom: 28px; -} - -.dashboard-section-header h2 { - margin: 0 0 6px 0; - font-size: 26px; - font-weight: 700; - color: #1e293b; - letter-spacing: -0.02em; -} - -.dashboard-section-header p { - margin: 0; - font-size: 15px; - color: #64748b; -} - -.dashboard-card { - background: #fff; - border-radius: 16px; - border: 1px solid rgba(0, 0, 0, 0.06); - box-shadow: - 0 1px 3px rgba(0, 0, 0, 0.04), - 0 4px 12px rgba(0, 0, 0, 0.03); -} - -/* Profile card */ -.dashboard-profile-card { - padding: 32px; - margin-bottom: 24px; - background: - radial-gradient(circle at 100% 0%, rgba(99, 102, 241, 0.06) 0%, transparent 50%), - radial-gradient(circle at 0% 100%, rgba(168, 85, 247, 0.04) 0%, transparent 40%), - #fff; -} - -.dashboard-profile-picture-section { - display: flex; - align-items: center; - gap: 24px; -} - -.dashboard-profile-avatar { - width: 96px; - height: 96px; - border-radius: 50%; - background-size: cover; - background-position: center; - background-color: #e2e8f0; - flex-shrink: 0; - position: relative; - cursor: pointer; - transition: transform 0.2s ease; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); -} - -.dashboard-profile-avatar:hover { - transform: scale(1.05); -} - -.dashboard-profile-avatar-overlay { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.5); - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.2s ease; -} - -.dashboard-profile-avatar:hover .dashboard-profile-avatar-overlay { - opacity: 1; -} - -.dashboard-profile-avatar-overlay svg { - width: 28px; - height: 28px; - color: white; -} - -.dashboard-profile-info { - display: flex; - flex-direction: column; - gap: 4px; -} - -.dashboard-profile-info h3 { - margin: 0; - font-size: 22px; - font-weight: 700; - color: #1e293b; -} - -.dashboard-profile-info p { - margin: 0; - font-size: 14px; - color: #64748b; -} - -.dashboard-profile-hint { - font-size: 12px; - color: #94a3b8; - margin-top: 4px; -} - -/* Settings grid */ -.dashboard-settings-grid { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 32px; -} - -.dashboard-settings-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - gap: 16px; -} - -.dashboard-settings-card-content { - display: flex; - align-items: center; - gap: 16px; - flex: 1; - min-width: 0; -} - -.dashboard-settings-card-icon { - width: 44px; - height: 44px; - border-radius: 12px; - background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.dashboard-settings-card-icon svg { - width: 22px; - height: 22px; - color: #475569; -} - -.dashboard-settings-card-info { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.dashboard-settings-card-info strong { - font-size: 14px; - font-weight: 600; - color: #1e293b; -} - -.dashboard-settings-card-info span { - font-size: 14px; - color: #64748b; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dashboard-settings-card .button { - flex-shrink: 0; -} - -.dashboard-settings-card-success { - border-color: #08bf4e; - background: #e6ffed; -} - -.dashboard-settings-card-success .dashboard-settings-card-info span { - color: #03933a; -} - -.dashboard-settings-card-warning { - border-color: #f0a500; - background: #fff7e6; -} - -.dashboard-settings-card-warning .dashboard-settings-card-info span { - color: #c98900; -} - -/* Danger zone */ -.dashboard-danger-zone { - padding-top: 24px; -} - -.dashboard-danger-zone h3 { - margin: 0 0 16px 0; - font-size: 14px; - font-weight: 600; - color: #dc2626; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.dashboard-danger-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - gap: 24px; - border-color: #fecaca; - background: linear-gradient(135deg, #fef2f2 0%, #fff 100%); -} - -.dashboard-danger-card-content { - flex: 1; - min-width: 0; -} - -.dashboard-danger-card-info { - display: flex; - flex-direction: column; - gap: 4px; -} - -.dashboard-danger-card-info strong { - font-size: 15px; - font-weight: 600; - color: #dc2626; -} - -.dashboard-danger-card-info span { - font-size: 13px; - color: #64748b; - line-height: 1.5; -} - -/* Responsive styles for Account tab */ -@media (max-width: 768px) { - .dashboard-tab-content { - padding: 0 16px; - } - - .dashboard-section-header h2 { - font-size: 22px; - } - - .dashboard-profile-card { - padding: 24px; - } - - .dashboard-profile-picture-section { - flex-direction: column; - text-align: center; - } - - .dashboard-profile-info { - align-items: center; - } - - .dashboard-settings-card { - flex-direction: column; - align-items: stretch; - gap: 16px; - padding: 16px 20px; - } - - .dashboard-settings-card .button { - width: 100%; - } - - .dashboard-danger-card { - flex-direction: column; - align-items: stretch; - gap: 16px; - } - - .dashboard-danger-card .button { - width: 100%; - } -} \ No newline at end of file diff --git a/src/gui/src/globals.js b/src/gui/src/globals.js index 4669d88fa..4701deb34 100644 --- a/src/gui/src/globals.js +++ b/src/gui/src/globals.js @@ -22,6 +22,8 @@ window.clipboard = []; window.actions_history = []; window.window_nav_history = {}; window.window_nav_history_current_position = {}; +window.dashboard_nav_history = []; +window.dashboard_nav_history_current_position = 0; window.progress_tracker = []; window.upload_item_global_id = 0; window.app_instance_ids = new Set(); diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 22a030b84..62fcdeac6 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -1694,6 +1694,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { suggested_apps: fsentry.suggested_apps, }; UIItem(options); + // In dashboard mode, also create item via dashboard's renderer + if ( window.is_dashboard_mode && window.UIDashboardFileItem ) { + window.UIDashboardFileItem(fsentry); + } moved_items.push({ 'options': options, 'original_path': $(el_item).attr('data-path') }); // this operation may have created some missing directories, @@ -1719,6 +1723,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { suggested_apps: dir.suggested_apps, }); } + // In dashboard mode, also create parent dirs via dashboard's renderer + if ( window.is_dashboard_mode && window.UIDashboardFileItem ) { + window.UIDashboardFileItem(dir); + } window.sort_items(item_container); }); @@ -2846,7 +2854,7 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i puter.fs.rename({ uid: options.uid === 'null' ? null : options.uid, new_name: new_name, - excludeSocketID: window.socket.id, + excludeSocketID: window.socket?.id, success: async (fsentry) => { // Add action to actions_history for undo ability if ( ! is_undo ) @@ -2975,30 +2983,27 @@ window.delete_item_with_path = async function (path) { }; window.undo_last_action = async () => { - if ( window.actions_history.length > 0 ) { - const last_action = window.actions_history.pop(); + if ( window.actions_history.length === 0 ) return; - // Undo the create file action - if ( last_action.operation === 'create_file' || last_action.operation === 'create_folder' ) { - const lastCreatedItem = last_action.data; - window.undo_create_file_or_folder(lastCreatedItem); - } else if ( last_action.operation === 'rename' ) { - const { options, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url } = last_action.data; + const last_action = window.actions_history.pop(); + const { operation, data } = last_action; + + // Map operations to their undo handlers + const undoHandlers = { + create_file: () => window.undo_create_file_or_folder(data), + create_folder: () => window.undo_create_file_or_folder(data), + upload: () => window.undo_upload(data), + copy: () => window.undo_copy(data), + move: () => window.undo_move(data), + delete: () => window.undo_delete(data), + rename: () => { + const { options, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url } = data; window.rename_file(options, old_name, new_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true); - } else if ( last_action.operation === 'upload' ) { - const files = last_action.data; - window.undo_upload(files); - } else if ( last_action.operation === 'copy' ) { - const files = last_action.data; - window.undo_copy(files); - } else if ( last_action.operation === 'move' ) { - const items = last_action.data; - window.undo_move(items); - } else if ( last_action.operation === 'delete' ) { - const items = last_action.data; - window.undo_delete(items); - } - } + }, + }; + + const handler = undoHandlers[operation]; + if ( handler ) handler(); }; window.undo_create_file_or_folder = async (item) => { diff --git a/src/gui/src/helpers/generate_file_context_menu.js b/src/gui/src/helpers/generate_file_context_menu.js new file mode 100644 index 000000000..314f9dc50 --- /dev/null +++ b/src/gui/src/helpers/generate_file_context_menu.js @@ -0,0 +1,723 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import UIAlert from '../UI/UIAlert.js'; +import UIWindowShare from '../UI/UIWindowShare.js'; +import UIWindowPublishWebsite from '../UI/UIWindowPublishWebsite.js'; +import UIWindowItemProperties from '../UI/UIWindowItemProperties.js'; +import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js'; +import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js'; +import UIWindowPublishWorker from '../UI/UIWindowPublishWorker.js'; +import open_item from './open_item.js'; +import launch_app from './launch_app.js'; +import path from '../lib/path.js'; +import mime from '../lib/mime.js'; + +const AI_APP_NAME = 'ai'; + +/** + * Parses item metadata for AI payload + * @param {string} metadata - JSON string of metadata + * @returns {Object|undefined} Parsed metadata or undefined + */ +const parseItemMetadataForAI = (metadata) => { + if ( ! metadata ) { + return undefined; + } + try { + return JSON.parse(metadata); + } catch ( error ) { + console.warn('Failed to parse item metadata for AI payload.', error); + return undefined; + } +}; + +/** + * Builds AI payload from item elements + * @param {jQuery} $elements - jQuery collection of elements + * @returns {Array} Array of item data for AI + */ +const buildAIPayloadFromItems = ($elements) => { + return $elements.get().map((element) => { + const $element = $(element); + return { + uid: $element.attr('data-uid'), + path: $element.attr('data-path'), + name: $element.attr('data-name'), + is_dir: $element.attr('data-is_dir') === '1', + is_shortcut: $element.attr('data-is_shortcut') === '1', + shortcut_to: $element.attr('data-shortcut_to') || undefined, + shortcut_to_path: $element.attr('data-shortcut_to_path') || undefined, + size: $element.attr('data-size') || undefined, + type: $element.attr('data-type') || undefined, + modified: $element.attr('data-modified') || undefined, + metadata: parseItemMetadataForAI($element.attr('data-metadata')), + }; + }); +}; + +/** + * Ensures AI app iframe is available + * @returns {Promise} AI app iframe or null + */ +const ensureAIAppIframe = async () => { + let $aiWindow = $(`.window[data-app="${AI_APP_NAME}"]`); + if ( $aiWindow.length === 0 ) { + try { + await launch_app({ name: AI_APP_NAME }); + } catch ( error ) { + console.error('Failed to launch AI app.', error); + return null; + } + $aiWindow = $(`.window[data-app="${AI_APP_NAME}"]`); + } + + if ( $aiWindow.length === 0 ) { + return null; + } + + $aiWindow.makeWindowVisible(); + const iframe = $aiWindow.find('.window-app-iframe').get(0); + return iframe ?? null; +}; + +/** + * Sends selection to AI app + * @param {jQuery} $elements - jQuery collection of elements + */ +const sendSelectionToAIApp = async ($elements) => { + const items = buildAIPayloadFromItems($elements); + if ( items.length === 0 ) { + return; + } + + const aiIframe = await ensureAIAppIframe(); + if ( !aiIframe || !aiIframe.contentWindow ) { + await UIAlert({ + message: i18n('ai_app_unavailable'), + }); + return; + } + + aiIframe.contentWindow.postMessage({ + msg: 'ai:openFsEntries', + items, + source: 'desktop-context-menu', + }, '*'); +}; + +/** + * Generates context menu items for file/folder operations + * + * @param {Object} options - Configuration options + * @param {HTMLElement} options.element - The DOM element representing the file/folder + * @param {Object} options.fsentry - File system entry data (uid, path, name, is_dir, etc.) + * @param {boolean} options.is_trash - Whether this is the trash folder + * @param {boolean} options.is_trashed - Whether item is in trash + * @param {Array} options.suggested_apps - Optional pre-loaded suggested apps + * @param {string} options.associated_app_name - Optional associated app + * @param {Function} options.onOpen - Optional custom open handler (used by Dashboard) + * @returns {Promise} Array of context menu items + */ +const generate_file_context_menu = async function (options) { + options = options || {}; + + const el_item = options.element; + const fsentry = options.fsentry || {}; + const is_trash = options.is_trash ?? false; + const is_trashed = options.is_trashed ?? false; + const is_worker = options.is_worker ?? false; + const onOpen = options.onOpen; + + const is_shared_with_me = (fsentry.path !== `/${window.user.username}` && !fsentry.path.startsWith(`/${window.user.username}/`)); + + let menu_items = []; + + // ------------------------------------------- + // Open + // ------------------------------------------- + if ( ! is_trashed ) { + menu_items.push({ + html: i18n('open'), + onClick: () => { + if ( onOpen ) { + onOpen(el_item, fsentry); + } else { + open_item({ item: el_item }); + } + }, + }); + + // ------------------------------------------- + // Separator + // ------------------------------------------- + if ( options.associated_app_name || is_trash ) { + menu_items.push('-'); + } + } + + // ------------------------------------------- + // Open With + // ------------------------------------------- + if ( !is_trashed && !is_trash && (options.associated_app_name === null || options.associated_app_name === undefined) ) { + const openWithItems = await generateOpenWithItems(el_item, fsentry, options.suggested_apps); + menu_items.push({ + html: i18n('open_with'), + items: openWithItems, + }); + + menu_items.push('-'); + } + + // ------------------------------------------- + // Open in New Window + // (only if the item is on a window) + // ------------------------------------------- + if ( $(el_item).closest('.window-body').length > 0 && fsentry.is_dir ) { + menu_items.push({ + html: i18n('open_in_new_window'), + onClick: function () { + if ( fsentry.is_dir ) { + open_item({ item: el_item, new_window: true }); + } + }, + }); + + // ------------------------------------------- + // Separator + // ------------------------------------------- + if ( !is_trash && !is_trashed && fsentry.is_dir ) { + menu_items.push('-'); + } + } + + // ------------------------------------------- + // Share With… + // ------------------------------------------- + if ( !is_trashed && !is_trash ) { + menu_items.push({ + html: i18n('Share With…'), + onClick: async function () { + if ( window.user.is_temp && + !await UIWindowSaveAccount({ + send_confirmation_code: true, + message: 'Please create an account to proceed.', + window_options: { + backdrop: true, + close_on_backdrop_click: false, + }, + }) ) { + return; + } + else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) { + return; + } + + const icon = $(el_item).find('.icon img').attr('src') || $(el_item).find('img').attr('src'); + UIWindowShare([{ + uid: $(el_item).attr('data-uid'), + path: $(el_item).attr('data-path'), + name: $(el_item).attr('data-name'), + icon: icon, + }]); + }, + }); + + // ------------------------------------------- + // Open in AI + // ------------------------------------------- + menu_items.push({ + html: i18n('open_in_ai'), + onClick: async function () { + await sendSelectionToAIApp($(el_item)); + }, + }); + } + + // ------------------------------------------- + // Publish As Website + // ------------------------------------------- + if ( !is_trashed && !is_trash && fsentry.is_dir ) { + menu_items.push({ + html: i18n('publish_as_website'), + disabled: !fsentry.is_dir || fsentry.has_website, + onClick: async function () { + if ( window.require_email_verification_to_publish_website ) { + if ( window.user.is_temp && + !await UIWindowSaveAccount({ + send_confirmation_code: true, + message: 'Please create an account to proceed.', + window_options: { + backdrop: true, + close_on_backdrop_click: false, + }, + }) ) { + return; + } + else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) { + return; + } + } + UIWindowPublishWebsite(fsentry.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path')); + }, + }); + } + + // ------------------------------------------- + // Publish as Worker + // ------------------------------------------- + if ( !is_trashed && !is_trash && !fsentry.is_dir && $(el_item).attr('data-name').toLowerCase().endsWith('.js') ) { + menu_items.push({ + html: i18n('publish_as_serverless_worker'), + disabled: is_worker, + onClick: async function () { + if ( window.user.is_temp && + !await UIWindowSaveAccount({ + send_confirmation_code: true, + message: 'Please create an account to proceed.', + window_options: { + backdrop: true, + close_on_backdrop_click: false, + }, + }) ) { + return; + } + else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) { + return; + } + + UIWindowPublishWorker(fsentry.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path')); + }, + }); + } + + // ------------------------------------------- + // Deploy As App + // ------------------------------------------- + if ( !is_trashed && !is_trash && fsentry.is_dir ) { + menu_items.push({ + html: i18n('deploy_as_app'), + disabled: !fsentry.is_dir, + onClick: async function () { + launch_app({ + name: 'dev-center', + file_path: $(el_item).attr('data-path'), + file_uid: $(el_item).attr('data-uid'), + params: { + source_path: fsentry.path, + }, + }); + }, + }); + + menu_items.push('-'); + } + + // ------------------------------------------- + // Empty Trash + // ------------------------------------------- + if ( is_trash ) { + menu_items.push({ + html: i18n('empty_trash'), + onClick: async function () { + window.empty_trash(); + }, + }); + } + + // ------------------------------------------- + // Download + // ------------------------------------------- + if ( !is_trash && !is_trashed && (options.associated_app_name === null || options.associated_app_name === undefined) ) { + menu_items.push({ + html: i18n('download'), + disabled: fsentry.is_dir && !window.feature_flags.download_directory, + onClick: async function () { + if ( fsentry.is_dir ) { + window.zipItems(el_item, path.dirname($(el_item).attr('data-path')), true); + } + else { + window.trigger_download([fsentry.path]); + } + }, + }); + } + + // ------------------------------------------- + // Set as Wallpaper + // ------------------------------------------- + const mime_type = mime.getType($(el_item).attr('data-name')) ?? 'application/octet-stream'; + if ( !window.dashboard_object && !is_trashed && !is_trash && !fsentry.is_dir && mime_type.startsWith('image/') ) { + menu_items.push({ + html: i18n('set_as_background'), + onClick: async function () { + const read_url = await puter.fs.sign(undefined, { uid: $(el_item).attr('data-uid'), action: 'read' }); + window.set_desktop_background({ + url: read_url.items.read_url, + fit: window.desktop_bg_fit, + }); + try { + $.ajax({ + url: `${window.api_origin}/set-desktop-bg`, + type: 'POST', + data: JSON.stringify({ + url: window.desktop_bg_url, + color: window.desktop_bg_color, + fit: window.desktop_bg_fit, + }), + async: true, + contentType: 'application/json', + headers: { + 'Authorization': `Bearer ${window.auth_token}`, + }, + statusCode: { + 401: function () { + window.logout(); + }, + }, + }); + } catch ( err ) { + // Ignore + } + }, + }); + } + + // ------------------------------------------- + // Zip + // ------------------------------------------- + if ( !is_trash && !is_trashed && !$(el_item).attr('data-path').endsWith('.zip') ) { + menu_items.push({ + html: i18n('zip'), + onClick: function () { + window.zipItems(el_item, path.dirname($(el_item).attr('data-path')), false); + }, + }); + } + + // ------------------------------------------- + // Unzip + // ------------------------------------------- + if ( !is_trash && !is_trashed && $(el_item).attr('data-path').endsWith('.zip') ) { + menu_items.push({ + html: i18n('unzip'), + onClick: async function () { + let filePath = $(el_item).attr('data-path'); + window.unzipItem(filePath); + }, + }); + } + + // ------------------------------------------- + // Tar + // ------------------------------------------- + if ( !is_trash && !is_trashed && !$(el_item).attr('data-path').endsWith('.tar') ) { + menu_items.push({ + html: i18n('tar'), + onClick: function () { + window.tarItems(el_item, path.dirname($(el_item).attr('data-path')), false); + }, + }); + } + + // ------------------------------------------- + // Untar + // ------------------------------------------- + if ( !is_trash && !is_trashed && $(el_item).attr('data-path').endsWith('.tar') ) { + menu_items.push({ + html: i18n('untar'), + onClick: async function () { + let filePath = $(el_item).attr('data-path'); + window.untarItem(filePath); + }, + }); + } + + // ------------------------------------------- + // Restore + // ------------------------------------------- + if ( is_trashed ) { + menu_items.push({ + html: i18n('restore'), + onClick: async function () { + await options.onRestore(el_item); + }, + }); + } + + // ------------------------------------------- + // Separator + // ------------------------------------------- + if ( !is_trash && (options.associated_app_name === null || options.associated_app_name === undefined) ) { + menu_items.push('-'); + } + + // ------------------------------------------- + // Cut + // ------------------------------------------- + if ( $(el_item).attr('data-immutable') === '0' && !is_shared_with_me ) { + menu_items.push({ + html: i18n('cut'), + onClick: function () { + window.clipboard_op = 'move'; + window.clipboard = [fsentry.path]; + }, + }); + } + + // ------------------------------------------- + // Copy + // ------------------------------------------- + if ( !is_trashed && !is_trash ) { + menu_items.push({ + html: i18n('copy'), + onClick: function () { + window.clipboard_op = 'copy'; + window.clipboard = [{ path: fsentry.path }]; + }, + }); + } + + // ------------------------------------------- + // Paste Into Folder + // ------------------------------------------- + if ( $(el_item).attr('data-is_dir') === '1' && !is_trashed && !is_trash ) { + menu_items.push({ + html: i18n('paste_into_folder'), + disabled: window.clipboard.length > 0 ? false : true, + onClick: function () { + if ( window.clipboard_op === 'copy' ) { + window.copy_clipboard_items($(el_item).attr('data-path'), null); + } + else if ( window.clipboard_op === 'move' ) { + window.move_clipboard_items(null, $(el_item).attr('data-path')); + } + }, + }); + } + + // ------------------------------------------- + // Separator + // ------------------------------------------- + if ( $(el_item).attr('data-immutable') === '0' && !is_trash ) { + menu_items.push('-'); + } + + // ------------------------------------------- + // Create Shortcut + // ------------------------------------------- + if ( !is_trashed && window.feature_flags.create_shortcut ) { + menu_items.push({ + html: is_shared_with_me ? i18n('create_desktop_shortcut') : i18n('create_shortcut'), + onClick: async function () { + let base_dir = path.dirname($(el_item).attr('data-path')); + // Trash on Desktop is a special case + if ( $(el_item).attr('data-path') && $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) { + base_dir = window.desktop_path; + } + + if ( is_shared_with_me ) base_dir = window.desktop_path; + + window.create_shortcut(path.basename($(el_item).attr('data-path')), + fsentry.is_dir, + base_dir, + null, // appendTo - will be determined by create_shortcut + fsentry.shortcut_to === '' ? fsentry.uid : fsentry.shortcut_to, + fsentry.shortcut_to_path === '' ? fsentry.path : fsentry.shortcut_to_path); + }, + }); + } + + // ------------------------------------------- + // Delete + // ------------------------------------------- + if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_shared_with_me ) { + menu_items.push({ + html: i18n('delete'), + onClick: async function () { + await window.move_items([el_item], window.trash_path); + }, + }); + } + + // ------------------------------------------- + // Delete Permanently + // ------------------------------------------- + if ( is_trashed ) { + menu_items.push({ + html: i18n('delete_permanently'), + onClick: async function () { + const alert_resp = await UIAlert({ + message: i18n('confirm_delete_single_item'), + buttons: [ + { + label: i18n('delete'), + type: 'primary', + }, + { + label: i18n('cancel'), + }, + ], + }); + + if ( (alert_resp) === 'Delete' ) { + await window.delete_item(el_item); + // check if trash is empty + const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' }); + // update other clients + if ( window.socket ) { + window.socket.emit('trash.is_empty', { is_empty: trash.is_empty }); + } + // update this client + if ( trash.is_empty ) { + $(`.item[data-path="${window.trash_path}" i], .item[data-shortcut_to_path="${window.trash_path}" i]`).find('.item-icon > img').attr('src', window.icons['trash.svg']); + $(`.window[data-path="${window.trash_path}"]`).find('.window-head-icon').attr('src', window.icons['trash.svg']); + } + } + }, + }); + } + + // ------------------------------------------- + // Rename + // ------------------------------------------- + if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash ) { + menu_items.push({ + html: i18n('rename'), + onClick: function () { + window.activate_item_name_editor(el_item); + }, + }); + } + + // ------------------------------------------- + // Separator + // ------------------------------------------- + menu_items.push('-'); + + // ------------------------------------------- + // Properties + // ------------------------------------------- + menu_items.push({ + html: i18n('properties'), + onClick: function () { + let window_height = 500; + let window_width = 450; + + let left = $(el_item).position().left + $(el_item).width(); + left = left > (window.innerWidth - window_width) ? (window.innerWidth - window_width) : left; + + let top = $(el_item).position().top + $(el_item).height(); + top = top > (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) ? (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) : top; + + UIWindowItemProperties($(el_item).attr('data-name'), + $(el_item).attr('data-path'), + $(el_item).attr('data-uid'), + left, + top, + window_width, + window_height); + }, + }); + + return menu_items; +}; + +/** + * Generates "Open With" menu items for a file + * + * @param {HTMLElement} el_item - The DOM element representing the file + * @param {Object} fsentry - File system entry data + * @param {Array} suggested_apps - Optional pre-loaded suggested apps + * @returns {Promise} Array of menu items for "Open With" submenu + */ +async function generateOpenWithItems (el_item, fsentry, suggested_apps) { + let items = []; + + // Try to find suitable apps if not provided + if ( !suggested_apps || suggested_apps.length === 0 ) { + const suitable_apps = await window.suggest_apps_for_fsentry({ + uid: fsentry.uid, + path: fsentry.path, + }); + if ( suitable_apps && suitable_apps.length > 0 ) { + suggested_apps = suitable_apps; + } + } + + if ( suggested_apps && suggested_apps.length > 0 ) { + for ( let index = 0; index < suggested_apps.length; index++ ) { + const suggested_app = suggested_apps[index]; + if ( ! suggested_app ) { + console.warn('suggested_app is null', suggested_apps, index); + continue; + } + items.push({ + html: suggested_app.title, + icon: ``, + onClick: async function () { + var extension = path.extname($(el_item).attr('data-path')).toLowerCase(); + if ( + window.user_preferences[`default_apps${extension}`] !== suggested_app.name + && + ( + (!window.user_preferences[`default_apps${extension}`] && index > 0) + || + (window.user_preferences[`default_apps${extension}`]) + ) + ) { + const alert_resp = await UIAlert({ + message: `${i18n('change_always_open_with')} ${html_encode(suggested_app.title)}?`, + body_icon: suggested_app.icon, + buttons: [ + { + label: i18n('yes'), + type: 'primary', + value: 'yes', + }, + { + label: i18n('no'), + }, + ], + }); + if ( (alert_resp) === 'yes' ) { + window.user_preferences[`default_apps${extension}`] = suggested_app.name; + window.mutate_user_preferences(window.user_preferences); + } + } + launch_app({ + name: suggested_app.name, + file_path: $(el_item).attr('data-path'), + window_title: $(el_item).attr('data-name'), + file_uid: $(el_item).attr('data-uid'), + }); + }, + }); + } + } else { + items.push({ + html: i18n('no_suitable_apps_found'), + disabled: true, + }); + } + + return items; +} + +export default generate_file_context_menu; diff --git a/src/gui/src/helpers/get_html_element_from_options.js b/src/gui/src/helpers/get_html_element_from_options.js index be72a3b91..542d6d035 100644 --- a/src/gui/src/helpers/get_html_element_from_options.js +++ b/src/gui/src/helpers/get_html_element_from_options.js @@ -36,6 +36,15 @@ const get_html_element_from_options = async function (options) { options.immutable = (options.immutable === false || options.immutable === 0 || options.immutable === undefined ? 0 : 1); options.sort_container_after_append = (options.sort_container_after_append !== undefined ? options.sort_container_after_append : false); const is_shared_with_me = (options.path !== `/${window.user.username}` && !options.path.startsWith(`/${window.user.username}/`)); + let worker_url; + let is_worker; + if ( ! options.is_dir ) { + const stats = await puter.fs.stat({ path: options.path, returnWorkers: true }); + is_worker = stats.workers !== undefined && stats.workers.length > 0;; + if ( is_worker ) { + worker_url = stats.workers[0].address; + } + } let website_url = window.determine_website_url(options.path); @@ -62,6 +71,8 @@ const get_html_element_from_options = async function (options) { data-website_url = "${website_url ? html_encode(website_url) : ''}" data-immutable="${options.immutable}" data-is_shortcut = "${options.is_shortcut}" + data-is_worker = "${is_worker !== undefined ? 1 : 0}" + data-worker_url = "${is_worker !== undefined ? worker_url : 0}" data-shortcut_to = "${html_encode(options.shortcut_to)}" data-shortcut_to_path = "${html_encode(options.shortcut_to_path)}" data-sortable = "${options.sortable ?? 'true'}" @@ -137,7 +148,12 @@ const get_html_element_from_options = async function (options) { data-item-id="${item_id}" title="Shortcut" >`; - + // worker badge + h += ``; h += '
'; // name diff --git a/src/gui/src/helpers/new_context_menu_item.js b/src/gui/src/helpers/new_context_menu_item.js index 9ad71dd8c..bb5cf1389 100644 --- a/src/gui/src/helpers/new_context_menu_item.js +++ b/src/gui/src/helpers/new_context_menu_item.js @@ -170,7 +170,7 @@ router.get('/', ({request}) => { return 'Hello World'; // returns a string }); router.get('/api/hello', ({request}) => { - return {'msg': 'hello'}; // returns a JSON object + return {'msg': 'hello'}; // returns a JSON object }); router.get('/*page', ({request, params}) => { return new Response(\`Page \${params.page} not found\`, {status: 404}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 016acc3c3..71c8c384b 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -321,6 +321,7 @@ const en = { taskmgr_header_type: 'Type', terms: 'Terms', text_document: 'Text document', + toggle_view: 'Toggle view', 'toolbar.enter_fullscreen': 'Enter Full Screen', 'toolbar.github': 'GitHub', 'toolbar.refer': 'Refer', diff --git a/src/gui/src/icons/worker.svg b/src/gui/src/icons/worker.svg new file mode 100644 index 000000000..9dfe587ca --- /dev/null +++ b/src/gui/src/icons/worker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index e2d62a768..aba48a669 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -156,8 +156,31 @@ if ( jQuery ) { // are we in dashboard mode? if ( window.location.pathname === '/dashboard' || window.location.pathname === '/dashboard/' ) { window.is_dashboard_mode = true; + window.dashboard_initial_route = parseDashboardRoute(); } +/** + * Parses the dashboard URL hash into a route object. + * Hash format: #files/username/Documents or #usage or #account etc. + * @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path + */ +function parseDashboardRoute () { + const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding + if ( ! hash ) return { tab: 'home', path: null }; + + const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents'] + const tab = parts[0]; // 'files', 'usage', 'account', 'security' + + if ( tab === 'files' && parts.length > 1 ) { + const filePath = `/${parts.slice(1).join('/')}`; // /username/Documents + return { tab: 'files', path: filePath }; + } + return { tab: tab || 'home', path: null }; +} + +// Make parseDashboardRoute available globally for hashchange handler +window.parseDashboardRoute = parseDashboardRoute; + /** * Shows a Turnstile challenge modal for first-time temp user creation * @param {Object} options - Configuration options