Files tab for dashboard view (#2331)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled

* Dashboard - Added files tab with basic folder and files listing.

* clearing out some linter errors

* Removed Public folder for now

* Added "modified" column
Subfolder are listed before the files

* Dashboard - files tab: context menu basics

* Dashboard - Files tab: subfolders can be opened. Items can be opened from more-menu or by double clicking on them

* Dashboard - Files tab: use UIContentMenu instead of custom menu for "more menu"

* Dashboard - Files tab: Delete implemented, Trash folder added

* refactored context menu rendering

* Implemented UIItem context menu functionalities
Added path

* Rename files and folders

* Rename fix

* tweaks and fixes

* WIP: Multiple selection, drag and drop, trash folder improvements

* clean

* Files handling improvements

* empty trash fix

* grid view

* grid view improvements

* update icons
context menu for multiple items shows file counts such as: Delete (9)

* Path navifation buttons

* better way to get user's directories

* Added icons for various file types

* Use puter.kv instead of window.user_preferences

* sorting and column resizing

* documentation of the functions

* Style update. Dashboard colors now as CSS variables

* refactored restoring from trash and emptying trash with proper callbacks & refreshing

* select documents folder by default
style tweaking

* Drag to select files and folders

* dark mode

* style tweaks

* removed some dead code..

* Keyboard shortcuts (copied from UIWindow - untested & in progress)

* Add drag-and-drop file upload support to Dashboard

Implement native file drag-and-drop from local filesystem to Dashboard:
- Add dragster handlers for main content area, sidebar folders, and folder rows
- Create uploadFiles() method with progress modal and directory refresh
- Add visual feedback CSS for drop zones (highlight on hover)
- Fix subfolder drop detection to prevent duplicate uploads
- Add callback support to unzipItem/untarItem for directory refresh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style tweaks

* Fix cut/paste functionality in Dashboard

The cut operation was storing clipboard items as plain path strings, but
window.move_clipboard_items() looks for .item DOM elements which don't
exist in Dashboard (it uses .row elements). Added a Dashboard-specific
moveClipboardItems() method that calls puter.fs.move() directly with UIDs,
and updated cut to store objects with path and uid like copy does.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add mobile phone layout optimizations for Dashboard

- Add responsive CSS for screens ≤480px with two-row file list layout
- Wrap item-size and item-modified in item-metadata div for mobile grid
- Hide directories sidebar on mobile (use breadcrumbs for navigation)
- Reorganize header with breadcrumbs on top row, nav buttons below
- Add single-tap to open folders on touch devices
- Fix context menu "Open" action in Dashboard by adding onOpen callback
- Hide column headers on mobile since metadata is shown per-item

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add CSS variables to Dashboard bento elements for dark mode support

Replace hardcoded color values with CSS custom properties throughout
the bento card components to enable proper dark mode theming. Adds new
variables for icon shadows, fancy headers, gradients, and usage bars
with appropriate values for both light and dark color schemes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add spacebar image preview feature to Dashboard Files

Pressing spacebar on a selected image file now shows a full-size preview
popover centered over the files container. Features include:
- Toggle behavior: press spacebar again to close
- Auto-switch: selecting another file updates the preview
- Click outside to dismiss
- Works in both grid and list view

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add floating action bar for multi-file selection in Dashboard Files

When 2+ files are selected, a floating action bar appears above the footer
with quick access buttons for common operations:
- Download, Cut, Copy, Delete for normal files
- Restore and Delete Permanently for items in Trash

The bar slides up with a smooth animation and disappears when clicking
the background to deselect. Includes mobile-responsive styling with
icon-only buttons on smaller screens.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add mobile select mode for multi-file selection in Dashboard Files

On mobile devices, users can now tap a select button in the path actions
to enter select mode. In this mode, tapping files toggles their selection
instead of opening them, with checkboxes appearing next to each item.
The floating action bar appears with 1+ selected items (vs 2+ on desktop)
and includes a Done button to exit select mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Center empty directory message in Dashboard Files

Replace row-based layout with centered flex positioning for the
"No files in this directory" message, improving visual presentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fixed issue where clicking on files background created blue dot (drag rectangle selection bug)

* Image preview size tweak

* Add URL-based routing to Dashboard with hash fragments

Enable shareable/bookmarkable URLs for Dashboard tabs and file paths.
The URL hash reflects current location (e.g., #files/username/Documents,
#usage, #account). Browser back/forward navigation now works for folder
navigation, and direct URL entry loads the correct tab and path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fixing draggable styles

* drag clone fixes

* Add smart column resizing and filename truncation to Dashboard Files

- Add middle-truncation for filenames preserving extensions (e.g., "longfile…name.txt")
- Double-click column resize handles to auto-fit content width
- Reset saved column widths to defaults if they don't fit smaller screens
- Apply truncation to both list and grid views

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Separated dashboard styles into dashboard.css and loading it dynamically only when navigating to /dashboard

* Files and folders can be dropped over path items in order to move them. This is useful when you want to move items into parent folders.

* Use custom context menu component for item-more menu on mobile devices

* Created callback system to undo_last_action so that dashboard file list would always refresh after undo operation

* gray update

* hovering folder while dragging items opens the folder

* Fixed issue with hover-dropping

* Fixed chained drag-over-folder-and-hover system

* dark mode tweak

* - [x] Right clicking on background of an empty folder doesn't trigger context menu
- [x] Right clicking on path should keep path item active
- [x] Sidebar folder don't have context menu
- [x] Drag item should be opaque so I can see through it
- [x] Transition animation issue when hovering path action buttons
- [x] Double clicking on column resizer: add padding
- [x] Load dashboard.css before loading the page content
- [x] Mobile: opening menu, clicking outside menu should close it
- [x] Mobile: on smaller screen there should be still two columns
- [x] Mobile: file name truncation in grid view doesn't work

* Added missing badges

* WIP has_worker

* display worker icon on the .js files which are workers

* Display worker badge on worker files, hovering badge shows worker URL.
Worker URL added to file properties modal.

* disable "publish as website" from context menu for folders that are already published

* Loading spinner for Files

* tweaks and fixes

* Files clean loading spinner

* Files: worker URL in the properties modal is clickable link

* use custom tooltip to display worker URL

* refactored renderDirectory

* Fix Sidebar folder highlighting

* add Beta badge on Files tab

* Implemeting socket based item updating

* Put newly added items into the position where they would land when list is sorted

* restore from trash refresh fix

* Revert "Revert "Adds worker badge to .js files which have been registered as workers. Hovering badge shows popover with worker URL. File properties modal also shows clickable worker URL. (#2462)""

This reverts commit c2750f2ca1.

* worker in stat

* update puterjs and frontend code to support returnWorkers in stat

* Better way of handling worker info

* Fixed issue where right clicking on background opened two instances of a context menu

* Fixes issue where while renaming a file, double clicking on word opened the file

* fixes issue where while dragging multiple items the top-most item was transparent resulting blurry mess

* when opening a folder, update view instantly

* Improved drag-to-select items

* Added feature where typing selects matching file items
Fixed issue where keyboard listener for file list was active when context menu was open

* Fixed issues with File -> New in context menu
Fixes issue where files-selection-actions menu didn't disappear after deleting items

* Improved drag to select behavior
Improved drag items
Improved worker badge handling

* Cancel zone for dragging items (when spring-loading subfolders)

* context menu's submenu tweak

* Fixed issue where resizing window didn't work (when using "Open in New Window" from context menu)

* Disabled buttons on .files-selection-actions while drag-to-select is happening

* Fixes issue where creating new items using right-clicking re-rendered the directory view

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: ProgrammerIn-wonderland <3838shah@gmail.com>
This commit is contained in:
Miika Kuisma
2026-02-17 10:50:08 -08:00
committed by GitHub
co-authored by Claude Opus 4.5 ProgrammerIn-wonderland
parent 4279cd92df
commit 9d598f7965
18 changed files with 8427 additions and 1438 deletions
@@ -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 = `
<div class="context-menu-items">
${this.renderMenuItems(menuItems)}
</div>
`;
// 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 '<div class="context-menu-separator"></div>';
}
// 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 = `<img src="${item.icon}" alt="" />`;
} else {
// HTML string (SVG)
iconHtml = item.icon;
}
}
return `
<button class="context-menu-item ${deleteClass}" data-index="${index}">
<div class="context-menu-item-icon">
${iconHtml}
</div>
<span class="context-menu-item-label">${label}</span>
</button>
`;
}).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 };
File diff suppressed because it is too large Load Diff
+97 -98
View File
@@ -28,7 +28,7 @@ function getTimeGreeting () {
function buildRecentAppsHTML () {
let h = '';
if ( window.launch_apps?.recent?.length > 0 ) {
h += '<div class="bento-recent-apps-grid">';
@@ -42,10 +42,10 @@ function buildRecentAppsHTML () {
}
h += `<div class="bento-recent-app" data-app-name="${html_encode(app_info.name)}" data-target-link="${html_encode(app_info.target_link)}">`;
// Icon
h += `<img class="bento-recent-app-icon" src="${html_encode(app_info.icon || window.icons['app.svg'])}">`;
// Title
h += `<span class="bento-recent-app-title">${html_encode(app_info.title)}</span>`;
// Icon
h += `<img class="bento-recent-app-icon" src="${html_encode(app_info.icon || window.icons['app.svg'])}">`;
// Title
h += `<span class="bento-recent-app-title">${html_encode(app_info.title)}</span>`;
h += '</div>';
}
h += '</div>';
@@ -59,57 +59,57 @@ function buildRecentAppsHTML () {
h += '<span>Apps you use will appear here</span>';
h += '</div>';
}
return h;
}
function buildUsageHTML () {
let h = '';
h += '<div class="bento-usage-grid">';
// Your Plan section
h += '<div class="bento-usage-section bento-usage-card bento-plan-section">';
h += '<a href="#" class="bento-usage-card-header bento-plan-header">';
h += `<h3>${i18n('your_plan')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
h += '<div class="bento-usage-card-info bento-plan-info">';
h += '<span class="bento-usage-card-used bento-plan-name">--</span>';
h += '<span class="bento-usage-card-details bento-plan-details"><span class="bento-plan-badge"></span></span>';
h += '</div>';
h += '<a href="#" class="bento-plan-upgrade" style="display: none;">Upgrade →</a>';
h += '<a href="#" class="bento-usage-card-header bento-plan-header">';
h += `<h3>${i18n('your_plan')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
h += '<div class="bento-usage-card-info bento-plan-info">';
h += '<span class="bento-usage-card-used bento-plan-name">--</span>';
h += '<span class="bento-usage-card-details bento-plan-details"><span class="bento-plan-badge"></span></span>';
h += '</div>';
h += '<a href="#" class="bento-plan-upgrade" style="display: none;">Upgrade →</a>';
h += '</div>';
// Storage section
h += '<div class="bento-usage-section bento-usage-card">';
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
h += `<h3>Your ${i18n('Storage')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
h += '<div class="bento-usage-card-bar-wrapper">';
h += '<div class="bento-usage-card-bar bento-storage-bar"></div>';
h += '</div>';
h += '<div class="bento-usage-card-info">';
h += '<span class="bento-usage-card-used bento-storage-used">-- Used</span>';
h += '<span class="bento-usage-card-details"><span class="bento-storage-percent">--%</span> of <span class="bento-storage-capacity">--</span></span>';
h += '</div>';
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
h += `<h3>Your ${i18n('Storage')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
h += '<div class="bento-usage-card-bar-wrapper">';
h += '<div class="bento-usage-card-bar bento-storage-bar"></div>';
h += '</div>';
h += '<div class="bento-usage-card-info">';
h += '<span class="bento-usage-card-used bento-storage-used">-- Used</span>';
h += '<span class="bento-usage-card-details"><span class="bento-storage-percent">--%</span> of <span class="bento-storage-capacity">--</span></span>';
h += '</div>';
h += '</div>';
// Resources section
h += '<div class="bento-usage-section bento-usage-card">';
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
h += `<h3>Your ${i18n('Resources')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
h += '<div class="bento-usage-card-bar-wrapper">';
h += '<div class="bento-usage-card-bar bento-resources-bar"></div>';
h += '</div>';
h += '<div class="bento-usage-card-info">';
h += '<span class="bento-usage-card-used bento-resources-used">-- Used</span>';
h += '<span class="bento-usage-card-details"><span class="bento-resources-percent">--%</span> of <span class="bento-resources-capacity">--</span></span>';
h += '</div>';
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
h += `<h3>Your ${i18n('Resources')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
h += '<div class="bento-usage-card-bar-wrapper">';
h += '<div class="bento-usage-card-bar bento-resources-bar"></div>';
h += '</div>';
h += '<div class="bento-usage-card-info">';
h += '<span class="bento-usage-card-used bento-resources-used">-- Used</span>';
h += '<span class="bento-usage-card-details"><span class="bento-resources-percent">--%</span> of <span class="bento-resources-capacity">--</span></span>';
h += '</div>';
h += '</div>';
h += '</div>';
return h;
}
@@ -117,74 +117,74 @@ function buildUsageHTML () {
const TabHome = {
id: 'home',
label: 'Home',
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
html () {
const username = window.user?.username || 'User';
const greeting = getTimeGreeting();
const profilePicture = window.user?.profile?.picture || window.icons['profile.svg'];
let h = '';
h += '<div class="bento-container">';
// Welcome card (square)
h += '<div class="bento-card bento-welcome">';
h += '<div class="bento-welcome-inner">';
h += '<div class="bento-welcome-pattern"></div>';
h += `<div class="bento-welcome-content">`;
h += `<div class="bento-welcome-avatar profile-pic" style="background-image: url(${html_encode(profilePicture)})"></div>`;
h += `<span class="bento-greeting">${greeting},</span>`;
h += `<h1 class="bento-username username">${html_encode(username)}</h1>`;
h += '<p class="bento-tagline">Your personal cloud computer</p>';
// Show warning if account is temporary/unsaved
if ( window.user?.is_temp ) {
h += '<button class="bento-save-account-warning">';
h += `<svg style="width: 16px; height: 16px;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px" height="48px" viewBox="0 0 48 48"><g transform="translate(0, 0)"><path d="M45.521,39.04L27.527,5.134c-1.021-1.948-3.427-2.699-5.375-1.679-.717,.376-1.303,.961-1.679,1.679L2.479,39.04c-.676,1.264-.635,2.791,.108,4.017,.716,1.207,2.017,1.946,3.42,1.943H41.993c1.403,.003,2.704-.736,3.42-1.943,.743-1.226,.784-2.753,.108-4.017ZM23.032,15h1.937c.565,0,1.017,.467,1,1.031l-.438,14c-.017,.54-.459,.969-1,.969h-1.062c-.54,0-.983-.429-1-.969l-.438-14c-.018-.564,.435-1.031,1-1.031Zm.968,25c-1.657,0-3-1.343-3-3s1.343-3,3-3,3,1.343,3,3-1.343,3-3,3Z" fill="#ffbb00"></path></g></svg>`;
h += `<span>${i18n('save_session')}</span>`;
h += '</button>';
}
h += '</div>';
h += '</div>';
h += '<div class="bento-welcome-inner">';
h += '<div class="bento-welcome-pattern"></div>';
h += '<div class="bento-welcome-content">';
h += `<div class="bento-welcome-avatar profile-pic" style="background-image: url(${html_encode(profilePicture)})"></div>`;
h += `<span class="bento-greeting">${greeting},</span>`;
h += `<h1 class="bento-username username">${html_encode(username)}</h1>`;
h += '<p class="bento-tagline">Your personal cloud computer</p>';
// Show warning if account is temporary/unsaved
if ( window.user?.is_temp ) {
h += '<button class="bento-save-account-warning">';
h += '<svg style="width: 16px; height: 16px;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px" height="48px" viewBox="0 0 48 48"><g transform="translate(0, 0)"><path d="M45.521,39.04L27.527,5.134c-1.021-1.948-3.427-2.699-5.375-1.679-.717,.376-1.303,.961-1.679,1.679L2.479,39.04c-.676,1.264-.635,2.791,.108,4.017,.716,1.207,2.017,1.946,3.42,1.943H41.993c1.403,.003,2.704-.736,3.42-1.943,.743-1.226,.784-2.753,.108-4.017ZM23.032,15h1.937c.565,0,1.017,.467,1,1.031l-.438,14c-.017,.54-.459,.969-1,.969h-1.062c-.54,0-.983-.429-1-.969l-.438-14c-.018-.564,.435-1.031,1-1.031Zm.968,25c-1.657,0-3-1.343-3-3s1.343-3,3-3,3,1.343,3,3-1.343,3-3,3Z" fill="var(--dashboard-warning-icon)"></path></g></svg>';
h += `<span>${i18n('save_session')}</span>`;
h += '</button>';
}
h += '</div>';
h += '</div>';
h += '</div>';
// Recent apps card (rectangle)
h += '<div class="bento-card bento-recent">';
h += '<div class="bento-card-fancy-header">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-apps">';
h += '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += '<h2>Apps</h2>';
h += '<span class="bento-card-fancy-subtitle">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
h += 'Recently used';
h += '</span>';
h += '</div>';
h += '</div>';
h += '<div class="bento-recent-apps-container">';
h += buildRecentAppsHTML();
h += '</div>';
h += '<div class="bento-card-fancy-header">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-apps">';
h += '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += '<h2>Apps</h2>';
h += '<span class="bento-card-fancy-subtitle">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
h += 'Recently used';
h += '</span>';
h += '</div>';
h += '</div>';
h += '<div class="bento-recent-apps-container">';
h += buildRecentAppsHTML();
h += '</div>';
h += '</div>';
// Usage card (spans full width on second row)
h += '<div class="bento-card bento-usage">';
h += '<div class="bento-card-fancy-header">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-usage">';
h += '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/><path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += `<h2>${i18n('usage')}</h2>`;
h += '<span class="bento-card-fancy-subtitle">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>';
h += 'Monthly overview';
h += '</span>';
h += '</div>';
h += '</div>';
h += '<div class="bento-usage-container">';
h += buildUsageHTML();
h += '</div>';
h += '<div class="bento-card-fancy-header">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-usage">';
h += '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/><path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += `<h2>${i18n('usage')}</h2>`;
h += '<span class="bento-card-fancy-subtitle">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>';
h += 'Monthly overview';
h += '</span>';
h += '</div>';
h += '</div>';
h += '<div class="bento-usage-container">';
h += buildUsageHTML();
h += '</div>';
h += '</div>';
h += '</div>';
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;
+38 -38
View File
@@ -26,68 +26,68 @@ import UIWindowManageSessions from '../UIWindowManageSessions.js';
const TabSecurity = {
id: 'security',
label: i18n('security'),
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
html () {
let h = '';
let user = window.user;
h += '<div class="dashboard-tab-content">';
// Section header
h += '<div class="dashboard-section-header">';
h += `<h2>${i18n('security')}</h2>`;
h += '<p>Manage your security settings and sessions</p>';
h += `<h2>${i18n('security')}</h2>`;
h += '<p>Manage your security settings and sessions</p>';
h += '</div>';
// Security settings cards
h += '<div class="dashboard-settings-grid">';
// Password card (only for non-temp users)
if ( !user.is_temp ) {
if ( ! user.is_temp ) {
h += '<div class="dashboard-card dashboard-settings-card">';
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('password')}</strong>`;
h += '<span>••••••••</span>';
h += '</div>';
h += '</div>';
h += `<button class="button change-password">${i18n('change_password')}</button>`;
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('password')}</strong>`;
h += '<span>••••••••</span>';
h += '</div>';
h += '</div>';
h += `<button class="button change-password">${i18n('change_password')}</button>`;
h += '</div>';
}
// Sessions card
h += '<div class="dashboard-card dashboard-settings-card">';
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('sessions')}</strong>`;
h += '<span>Manage active sessions</span>';
h += '</div>';
h += '</div>';
h += `<button class="button manage-sessions">${i18n('manage_sessions')}</button>`;
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('sessions')}</strong>`;
h += '<span>Manage active sessions</span>';
h += '</div>';
h += '</div>';
h += `<button class="button manage-sessions">${i18n('manage_sessions')}</button>`;
h += '</div>';
// 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 += `<div class="dashboard-card dashboard-settings-card dashboard-settings-card-2fa ${twoFaStatusClass}">`;
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('two_factor')}</strong>`;
h += `<span class="user-otp-state">${i18n(user.otp ? 'two_factor_enabled' : 'two_factor_disabled')}</span>`;
h += '</div>';
h += '</div>';
h += `<button class="button enable-2fa" style="${user.otp ? 'display:none;' : ''}">${i18n('enable_2fa')}</button>`;
h += `<button class="button disable-2fa" style="${user.otp ? '' : 'display:none;'}">${i18n('disable_2fa')}</button>`;
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('two_factor')}</strong>`;
h += `<span class="user-otp-state">${i18n(user.otp ? 'two_factor_enabled' : 'two_factor_disabled')}</span>`;
h += '</div>';
h += '</div>';
h += `<button class="button enable-2fa" style="${user.otp ? 'display:none;' : ''}">${i18n('enable_2fa')}</button>`;
h += `<button class="button disable-2fa" style="${user.otp ? '' : 'display:none;'}">${i18n('disable_2fa')}</button>`;
h += '</div>';
}
@@ -185,7 +185,7 @@ const TabSecurity = {
h += '</div>';
h += '<div style="display: flex; flex-direction: column; gap: 10pt;">';
h += '<input type="password" class="password-entry" />';
h += '<span class="error-message" style="color: red; display: none;"></span>';
h += '<span class="error-message" style="color: var(--dashboard-error-text); display: none;"></span>';
h += '</div>';
h += '<div style="display: flex; gap: 5pt;">';
h += `<button class="button confirm-disable-2fa">${i18n('disable_2fa')}</button>`;
@@ -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',
},
+270 -23
View File
@@ -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 += '<div class="dashboard">';
// Mobile sidebar toggle
h += '<button class="dashboard-sidebar-toggle">';
h += '<span></span><span></span><span></span>';
h += '</button>';
// Sidebar
h += '<div class="dashboard-sidebar hide-scrollbar">';
// 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 += `<div class="dashboard-sidebar-item${isActive}" data-section="${tab.id}">`;
const isBeta = tab.label === 'Files';
h += `<div class="dashboard-sidebar-item${isActive} ${isBeta ? 'beta' : ''}" data-section="${tab.id}">`;
h += tab.icon;
h += tab.label;
h += '</div>';
}
h += '</div>';
// User options button at bottom
h += '<div class="dashboard-user-options hide-scrollbar">';
h += `<div class="dashboard-user-btn hide-scrollbar">`;
h += '<div class="dashboard-user-btn hide-scrollbar">';
h += `<div class="dashboard-user-avatar profile-pic" style="background-image: url(${window.user?.profile?.picture || window.icons['profile.svg']})"></div>`;
h += `<span class="dashboard-user-name">${html_encode(window.user?.username || 'User')}</span>`;
h += `<svg class="dashboard-user-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>`;
h += `</div>`;
h += `<span class="dashboard-user-name">${window.html_encode(window.user?.username || 'User')}</span>`;
h += '<svg class="dashboard-user-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>';
h += '</div>';
h += '</div>';
h += '</div>';
@@ -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: '<svg style="margin-bottom: -4px; width: 16px; height: 16px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M45.521,39.04L27.527,5.134c-1.021-1.948-3.427-2.699-5.375-1.679-.717,.376-1.303,.961-1.679,1.679L2.479,39.04c-.676,1.264-.635,2.791,.108,4.017,.716,1.207,2.017,1.946,3.42,1.943H41.993c1.403,.003,2.704-.736,3.42-1.943,.743-1.226,.784-2.753,.108-4.017ZM23.032,15h1.937c.565,0,1.017,.467,1,1.031l-.438,14c-.017,.54-.459,.969-1,.969h-1.062c-.54,0-.983-.429-1-.969l-.438-14c-.018-.564,.435-1.031,1-1.031Zm.968,25c-1.657,0-3-1.343-3-3s1.343-3,3-3,3,1.343,3,3-1.343,3-3,3Z" fill="#ffbb00"/></svg>',
icon: '<svg style="margin-bottom: -4px; width: 16px; height: 16px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="M45.521,39.04L27.527,5.134c-1.021-1.948-3.427-2.699-5.375-1.679-.717,.376-1.303,.961-1.679,1.679L2.479,39.04c-.676,1.264-.635,2.791,.108,4.017,.716,1.207,2.017,1.946,3.42,1.943H41.993c1.403,.003,2.704-.736,3.42-1.943,.743-1.226,.784-2.753,.108-4.017ZM23.032,15h1.937c.565,0,1.017,.467,1,1.031l-.438,14c-.017,.54-.459,.969-1,.969h-1.062c-.54,0-.983-.429-1-.969l-.438-14c-.018-.564,.435-1.031,1-1.031Zm.968,25c-1.657,0-3-1.343-3-3s1.343-3,3-3,3,1.343,3,3-1.343,3-3,3Z" fill="var(--dashboard-warning-icon)"/></svg>',
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: `<p>${i18n('confirm_open_apps_log_out')}</p>`,
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');
}
},
});
});
+63 -3
View File
@@ -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 += `<img class="item-badge item-is-worker long-hover"
style="background-color: #ffffff; padding: 2px; ${is_worker ? 'display:block;' : ''}"
src="${html_encode(window.icons['worker.svg'])}"
data-item-id="${item_id}"
>`;
h += '</div>';
// 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 = '<div class="allow-user-select worker-badge-popover-content">';
h += `<div class="worker-badge-popover-title">${i18n('worker')}</div>`;
h += `
<a class="worker-badge-popover-link" href="${worker_url}" style="font-size:13px;" target="_blank">${worker_url.replace('https://', '')}</a>
<br>`;
h += '</div>';
// 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 || {};
+11 -4
View File
@@ -42,6 +42,7 @@ async function UIWindowItemProperties (item_name, item_path, item_uid, left, top
h += `<tr><td class="item-prop-label">${i18n('modified')}</td><td class="item-prop-val item-prop-val-modified"></td></tr>`;
h += `<tr><td class="item-prop-label">${i18n('created')}</td><td class="item-prop-val item-prop-val-created"></td></tr>`;
h += `<tr><td class="item-prop-label">${i18n('versions')}</td><td class="item-prop-val item-prop-val-versions"></td></tr>`;
h += `<tr><td class="item-prop-label">${i18n('worker')}</td><td class="item-prop-val item-prop-val-worker">`;
h += `<tr><td class="item-prop-label">${i18n('associated_websites')}</td><td class="item-prop-val item-prop-val-websites">`;
h += '</td></tr>';
h += `<tr><td class="item-prop-label">${i18n('access_granted_to')}</td><td class="item-prop-val item-prop-val-permissions"></td></tr>`;
@@ -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(`<a target="_blank" href="${html_encode(worker_url)}">${html_encode(worker_url)}</a>`);
}
}
$(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);
}
});
});
});
},
});
-2
View File
@@ -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();
}
File diff suppressed because it is too large Load Diff
+87 -1237
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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();
+28 -23
View File
@@ -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) => {
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<HTMLIFrameElement|null>} 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>} 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>} 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: `<img src="${html_encode(suggested_app.icon ?? window.icons['app.svg'])}" style="width:16px; height: 16px; margin-bottom: -4px;">`,
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;
@@ -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 += `<img class="item-badge item-is-worker long-hover"
style="background-color: #ffffff; padding: 2px; ${is_worker ? 'display:block;' : ''}"
src="${html_encode(window.icons['worker.svg'])}"
data-item-id="${item_id}"
>`;
h += '</div>';
// name
+1 -1
View File
@@ -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});
+1
View File
@@ -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',
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" color="#455a64" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-zap-icon lucide-zap"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>

After

Width:  |  Height:  |  Size: 412 B

+23
View File
@@ -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