From ed509ad2862560e9845d0894c8680dcd8adba8f3 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sat, 6 Dec 2025 17:06:20 -0800 Subject: [PATCH] Dashboard (#2104) * Add dashboard UI and routing support Introduces a new Dashboard UI component with sidebar navigation and user options. Updates backend routing to redirect /dashboard to the root path. Integrates dashboard mode detection and initialization in the GUI, including responsive styles and logic to open the dashboard or desktop as appropriate. * Improve dashboard user menu and UI behavior Added support for multiple logged-in users, session saving for temporary users, and improved context menu options in the dashboard. Updated CSS for user button state and adjusted font size for signup terms. Changed dashboard initialization to use UIDashboard instead of UIWindow. * Add dynamic apps section to dashboard UI * Add specific class for dashboard apps section * Refactor dashboard tabs into modular components * Update style.css * Add Developers menu and improve signup window behavior Added a 'Developers' menu item to the dashboard linking to developer.puter.com. Updated login and signup window logic to ensure the signup window opens centered and dominant, improving user experience when transitioning from login to signup. * Update UIWindowLogin.js * Refactor apps tab UI and improve app card interaction --- src/backend/src/routers/_default.js | 4 + src/gui/src/UI/Dashboard/TabApps.js | 99 ++++++++ src/gui/src/UI/Dashboard/TabFiles.js | 38 +++ src/gui/src/UI/Dashboard/UIDashboard.js | 281 ++++++++++++++++++++++ src/gui/src/UI/UIWindowSignup.js | 3 +- src/gui/src/css/style.css | 298 +++++++++++++++++++++++- src/gui/src/initgui.js | 28 ++- 7 files changed, 745 insertions(+), 6 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/TabApps.js create mode 100644 src/gui/src/UI/Dashboard/TabFiles.js create mode 100644 src/gui/src/UI/Dashboard/UIDashboard.js diff --git a/src/backend/src/routers/_default.js b/src/backend/src/routers/_default.js index 6401f84c..072ec2ce 100644 --- a/src/backend/src/routers/_default.js +++ b/src/backend/src/routers/_default.js @@ -346,6 +346,10 @@ router.all('*', async function (req, res, next) { else if ( path.startsWith('/settings') ) { path = '/'; } + // /dashboard + else if ( path === '/dashboard' || path === '/dashboard/' ) { + path = '/'; + } // /app/ else if ( path.startsWith('/app/') ) { app_name = path.replace('/app/', ''); diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js new file mode 100644 index 00000000..4a46bac9 --- /dev/null +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -0,0 +1,99 @@ +/** + * 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 . + */ + +function buildAppsSection () { + let apps_str = ''; + if ( window.launch_apps?.recommended?.length > 0 ) { + apps_str += '
'; + for ( let index = 0; index < window.launch_apps.recommended.length; index++ ) { + const app_info = window.launch_apps.recommended[index]; + apps_str += `
`; + apps_str += `
`; + apps_str += ``; + apps_str += `${html_encode(app_info.title)}`; + apps_str += '
'; + apps_str += '
'; + } + apps_str += '
'; + } + + // No apps message + if ( (!window.launch_apps?.recent || window.launch_apps.recent.length === 0) && + (!window.launch_apps?.recommended || window.launch_apps.recommended.length === 0) ) { + apps_str += '

No apps available yet.

'; + } + + return apps_str; +} + +const TabApps = { + id: 'apps', + label: 'My Apps', + icon: ``, + + html () { + return '
'; + }, + + init ($el_window) { + // Load apps initially + this.loadApps($el_window); + + // Handle app clicks - open in new browser tab + $el_window.on('click', '.dashboard-apps-container .start-app', function (e) { + e.preventDefault(); + e.stopPropagation(); + + const appName = $(this).attr('data-app-name'); + if ( appName ) { + const appUrl = `/app/${appName}`; + window.open(appUrl, '_blank'); + } + }); + }, + + async loadApps ($el_window) { + // If launch_apps is not populated yet, fetch from server + if ( !window.launch_apps || !window.launch_apps.recent || window.launch_apps.recent.length === 0 ) { + try { + window.launch_apps = await $.ajax({ + url: `${window.api_origin}/get-launch-apps?icon_size=64`, + type: 'GET', + async: true, + contentType: 'application/json', + headers: { + 'Authorization': `Bearer ${window.auth_token}`, + }, + }); + } catch (e) { + console.error('Failed to load launch apps:', e); + } + } + // Populate the apps container + $el_window.find('.dashboard-apps-container').html(buildAppsSection()); + }, + + onActivate ($el_window) { + // Refresh apps when navigating to apps section + this.loadApps($el_window); + }, +}; + +export default TabApps; + diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js new file mode 100644 index 00000000..9cdcad1c --- /dev/null +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -0,0 +1,38 @@ +/** + * 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 . + */ + +const TabFiles = { + id: 'files', + label: 'My Files', + icon: ``, + + html () { + let h = ''; + h += '

My Files

'; + h += '

Your files will appear here.

'; + return h; + }, + + init ($el_window) { + // Files tab initialization logic can go here + }, +}; + +export default TabFiles; + diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js new file mode 100644 index 00000000..021be251 --- /dev/null +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -0,0 +1,281 @@ +/** + * 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 UIWindow from '../UIWindow.js'; +import UIContextMenu from '../UIContextMenu.js'; +import UIWindowSettings from '../Settings/UIWindowSettings.js'; +import UIAlert from '../UIAlert.js'; +import UIWindowSaveAccount from '../UIWindowSaveAccount.js'; +import UIWindowLogin from '../UIWindowLogin.js'; +import UIWindowFeedback from '../UIWindowFeedback.js'; + +// Import tab modules +import TabFiles from './TabFiles.js'; +import TabApps from './TabApps.js'; + +// Registry of all available tabs +const tabs = [ + TabFiles, + TabApps, +]; + +async function UIDashboard (options) { + options = options ?? {}; + + let h = ''; + + h += '
'; + + // Mobile sidebar toggle + h += ''; + + // Sidebar + h += '
'; + // Navigation items container + h += '
'; + for ( let i = 0; i < tabs.length; i++ ) { + const tab = tabs[i]; + const isActive = i === 0 ? ' active' : ''; + h += `
`; + h += tab.icon; + h += tab.label; + h += '
'; + } + h += '
'; + + // User options button at bottom + h += '
'; + h += `
`; + h += `
`; + h += `${html_encode(window.user?.username || 'User')}`; + h += ``; + h += `
`; + h += '
'; + h += '
'; + + // Main content area + h += '
'; + for ( let i = 0; i < tabs.length; i++ ) { + const tab = tabs[i]; + const isActive = i === 0 ? ' active' : ''; + h += `
`; + h += tab.html(); + h += '
'; + } + h += '
'; + + h += '
'; + + const el_window = await UIWindow({ + title: 'Dashboard', + app: 'dashboard', + single_instance: true, + is_fullpage: true, + is_resizable: false, + is_maximized: true, + has_head: false, + body_content: h, + window_class: 'window-dashboard', + body_css: { + height: '100%', + overflow: 'hidden', + }, + }); + + const $el_window = $(el_window); + + // Initialize all tabs + for ( const tab of tabs ) { + if ( tab.init ) { + tab.init($el_window); + } + } + + // 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'); + + // Call onActivate for the tab if it exists + const tab = tabs.find(t => t.id === section); + if ( tab && tab.onActivate ) { + tab.onActivate($el_window); + } + + // Close sidebar on mobile after selection + $el_window.find('.dashboard-sidebar').removeClass('open'); + $el_window.find('.dashboard-sidebar-toggle').removeClass('open'); + }); + + // Mobile toggle handler + $el_window.on('click', '.dashboard-sidebar-toggle', function () { + $(this).toggleClass('open'); + $el_window.find('.dashboard-sidebar').toggleClass('open'); + }); + + // User options button click handler + $el_window.on('click', '.dashboard-user-btn', function (e) { + const $btn = $(this); + const pos = this.getBoundingClientRect(); + + // Don't open if already open + if ($('.context-menu[data-id="dashboard-user-menu"]').length > 0) { + return; + } + + let items = []; + + // Save Session (if temp user) + if (window.user.is_temp) { + items.push({ + html: i18n('save_session'), + icon: '', + onClick: async function () { + UIWindowSaveAccount({ + send_confirmation_code: false, + default_username: window.user.username, + }); + }, + }); + items.push('-'); + } + + // Logged in users + if (window.logged_in_users.length > 0) { + let users_arr = window.logged_in_users; + + // bring logged in user's item to top + users_arr.sort(function (x, y) { + return x.uuid === window.user.uuid ? -1 : y.uuid == window.user.uuid ? 1 : 0; + }); + + // create menu items for each user + users_arr.forEach(l_user => { + items.push({ + html: l_user.username, + icon: l_user.username === window.user.username ? '✓' : '', + onClick: async function () { + if (l_user.username === window.user.username) { + return; + } + window.update_auth_data(l_user.auth_token, l_user); + location.reload(); + }, + }); + }); + + items.push('-'); + + items.push({ + html: i18n('add_existing_account'), + onClick: async function () { + await UIWindowLogin({ + reload_on_success: true, + send_confirmation_code: false, + window_options: { + has_head: true, + stay_on_top: true, + }, + }); + }, + }); + + items.push('-'); + } + + // Build final menu items + const menuItems = [ + ...items, + // Settings + { + html: i18n('settings'), + onClick: async function () { + UIWindowSettings(); + }, + }, + // Developer + { + html: 'Developers', + html_active: 'Developers', + onClick: function () { + window.open('https://developer.puter.com', '_blank'); + }, + }, + // Contact Us + { + html: i18n('contact_us'), + onClick: async function () { + UIWindowFeedback(); + }, + }, + '-', + // Log out + { + html: i18n('log_out'), + onClick: async function () { + // Check for open windows + if ($('.window-app').length > 0) { + const alert_resp = await UIAlert({ + message: `

${i18n('confirm_open_apps_log_out')}

`, + buttons: [ + { + label: i18n('close_all_windows_and_log_out'), + value: 'close_and_log_out', + type: 'primary', + }, + { + label: i18n('cancel'), + }, + ], + }); + if (alert_resp === 'close_and_log_out') { + window.logout(); + } + } else { + window.logout(); + } + }, + }, + ]; + + UIContextMenu({ + id: 'dashboard-user-menu', + parent_element: $btn[0], + position: { + top: pos.top - 8, + left: pos.left + }, + items: menuItems + }); + }); + + return el_window; +} + +export default UIDashboard; diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 46ca9a03..d69d60ab 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -44,6 +44,7 @@ function UIWindowSignup (options) { // Form h += '
'; + // title h += `

${i18n('create_free_account')}

`; // signup form @@ -122,7 +123,7 @@ function UIWindowSignup (options) { allow_native_ctxmenu: true, allow_user_select: true, ...options.window_options, - dominant: false, + dominant: true, center: true, onAppend: function (el_window) { $(el_window).find('.username').get(0).focus({ preventScroll: true }); diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 88d6fabe..9e9e2f5e 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -4017,7 +4017,7 @@ fieldset[name=number-code] { } .signup-terms { - font-size: 11px; + font-size: 10px; color: #666; margin-top: 10px; bottom: 10px; @@ -5436,4 +5436,300 @@ fieldset[name=number-code] { .update-usage-details svg{ 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; +} + +.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; +} + +.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 { + color: #666; + font-size: 14px; +} + +.dashboard-section-apps { + 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: 8px; + 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; +} +.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; + width: 100%; +} + +.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.1); +} + +.dashboard-app-title { + font-size: 12px; + color: #333; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + line-height: 1.3; +} + +.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); +} + +/* Responsive: tablet and below */ +@media (max-width: 768px) { + .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; + } } \ No newline at end of file diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index db47f742..aadca0b9 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -47,6 +47,7 @@ import { ProcessService } from './services/ProcessService.js'; import { SettingsService } from './services/SettingsService.js'; import { ThemeService } from './services/ThemeService.js'; import { privacy_aware_path } from './util/desktop.js'; +import UIDashboard from './UI/Dashboard/UIDashboard.js'; const launch_services = async function (options) { // === Services Data Structures === @@ -150,6 +151,11 @@ if ( jQuery ) { }; } +// are we in dashboard mode? +if(window.location.pathname === '/dashboard' || window.location.pathname === '/dashboard/'){ + window.is_dashboard_mode = true; +} + /** * Shows a Turnstile challenge modal for first-time temp user creation * @param {Object} options - Configuration options @@ -348,6 +354,8 @@ window.initgui = async function (options) { // Puter is in fullpage mode. window.is_fullpage_mode = true; + } else if (window.is_dashboard_mode) { + window.is_fullpage_mode = true; } // Launch services before any UI is rendered @@ -601,15 +609,21 @@ window.initgui = async function (options) { window.update_auth_data(whoami.token || window.auth_token, whoami); // ------------------------------------------------------------------------------------- - // Load desktop, only if we're not embedded in a popup + // Load desktop, only if we're not embedded in a popup and not on the dashboard page // ------------------------------------------------------------------------------------- - if ( ! window.embedded_in_popup ) { + if ( ! window.embedded_in_popup && ! window.is_dashboard_mode ) { await window.get_auto_arrange_data(); puter.fs.stat({ path: window.desktop_path, consistency: 'eventual' }).then(desktop_fsentry => { UIDesktop({ desktop_fsentry: desktop_fsentry }); }); } // ------------------------------------------------------------------------------------- + // Dashboard mode + // ------------------------------------------------------------------------------------- + else if ( window.is_dashboard_mode ) { + UIDashboard(); + } + // ------------------------------------------------------------------------------------- // If embedded in a popup, send the token to the opener and close the popup // ------------------------------------------------------------------------------------- else { @@ -1107,15 +1121,21 @@ window.initgui = async function (options) { $('.window').close(); // ------------------------------------------------------------------------------------- - // Load desktop, if not embedded in a popup + // Load desktop, if not embedded in a popup and not on the dashboard page // ------------------------------------------------------------------------------------- - if ( ! window.embedded_in_popup ) { + if ( ! window.embedded_in_popup && ! window.is_dashboard_mode ) { await window.get_auto_arrange_data(); puter.fs.stat({ path: window.desktop_path, consistency: 'eventual' }).then(desktop_fsentry => { UIDesktop({ desktop_fsentry: desktop_fsentry }); }); } // ------------------------------------------------------------------------------------- + // Dashboard mode: open explorer pointing to home directory + // ------------------------------------------------------------------------------------- + else if ( window.is_dashboard_mode ) { + UIDashboard(); + } + // ------------------------------------------------------------------------------------- // If embedded in a popup, send the 'ready' event to referrer and close the popup // ------------------------------------------------------------------------------------- else {