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